diff --git a/app/models/simplefin_account/investments/transactions_processor.rb b/app/models/simplefin_account/investments/transactions_processor.rb index 6644a64dc..09d89f3ab 100644 --- a/app/models/simplefin_account/investments/transactions_processor.rb +++ b/app/models/simplefin_account/investments/transactions_processor.rb @@ -1,85 +1,33 @@ # SimpleFin Investment transactions processor -# Processes investment-specific transactions like trades, dividends, etc. +# +# NOTE: SimpleFIN transactions (dividends, contributions, etc.) for investment accounts +# are already processed by SimplefinAccount::Transactions::Processor, which handles ALL +# account types including investments. That processor uses SimplefinEntry::Processor +# which captures full metadata (merchant, notes, extra data). +# +# This processor is intentionally a no-op for transactions to avoid: +# 1. Duplicate processing of the same transactions +# 2. Overwriting richer data with less complete data +# +# Unlike Plaid (which has a separate investment_transactions endpoint), SimpleFIN returns +# all transactions in a single `transactions` array regardless of account type. +# +# Holdings are processed separately by SimplefinAccount::Investments::HoldingsProcessor. class SimplefinAccount::Investments::TransactionsProcessor def initialize(simplefin_account) @simplefin_account = simplefin_account end def process - return unless simplefin_account.current_account&.accountable_type == "Investment" - return unless simplefin_account.raw_transactions_payload.present? - - transactions_data = simplefin_account.raw_transactions_payload - - transactions_data.each do |transaction_data| - process_investment_transaction(transaction_data) - end + # Intentionally a no-op for transactions. + # SimpleFIN investment transactions are already processed by the regular + # SimplefinAccount::Transactions::Processor which handles all account types. + # + # This avoids duplicate processing and ensures the richer metadata from + # SimplefinEntry::Processor (merchant, notes, extra) is preserved. + Rails.logger.debug "SimplefinAccount::Investments::TransactionsProcessor - Skipping (transactions handled by SimplefinAccount::Transactions::Processor)" end private attr_reader :simplefin_account - - def account - simplefin_account.current_account - end - - def process_investment_transaction(transaction_data) - data = transaction_data.with_indifferent_access - - amount = parse_amount(data[:amount]) - posted_date = parse_date(data[:posted]) - external_id = "simplefin_#{data[:id]}" - - # Use the unified import adapter for consistent handling - import_adapter.import_transaction( - external_id: external_id, - amount: amount, - currency: account.currency, - date: posted_date, - name: data[:description] || "Investment transaction", - source: "simplefin" - ) - rescue => e - Rails.logger.error("Failed to process SimpleFin investment transaction #{data[:id]}: #{e.message}") - end - - def import_adapter - @import_adapter ||= Account::ProviderImportAdapter.new(account) - end - - def parse_amount(amount_value) - parsed_amount = case amount_value - when String - BigDecimal(amount_value) - when Numeric - BigDecimal(amount_value.to_s) - else - BigDecimal("0") - end - - # SimpleFin uses banking convention, Maybe expects opposite - -parsed_amount - rescue ArgumentError => e - Rails.logger.error "Failed to parse SimpleFin investment transaction amount: #{amount_value.inspect} - #{e.message}" - BigDecimal("0") - end - - def parse_date(date_value) - case date_value - when String - Date.parse(date_value) - when Integer, Float - Time.at(date_value).to_date - when Time, DateTime - date_value.to_date - when Date - date_value - else - Rails.logger.error("SimpleFin investment transaction has invalid date value: #{date_value.inspect}") - raise ArgumentError, "Invalid date format: #{date_value.inspect}" - end - rescue ArgumentError, TypeError => e - Rails.logger.error("Failed to parse SimpleFin investment transaction date '#{date_value}': #{e.message}") - raise ArgumentError, "Unable to parse transaction date: #{date_value.inspect}" - end end diff --git a/app/models/simplefin_account/transactions/processor.rb b/app/models/simplefin_account/transactions/processor.rb index ea4725066..da4c96dda 100644 --- a/app/models/simplefin_account/transactions/processor.rb +++ b/app/models/simplefin_account/transactions/processor.rb @@ -6,18 +6,40 @@ class SimplefinAccount::Transactions::Processor end def process - return unless simplefin_account.raw_transactions_payload.present? + transactions = simplefin_account.raw_transactions_payload.to_a + acct = simplefin_account.current_account + acct_info = acct ? "Account id=#{acct.id} name='#{acct.name}' type=#{acct.accountable_type}" : "NO LINKED ACCOUNT" + + if transactions.empty? + Rails.logger.info "SimplefinAccount::Transactions::Processor - No transactions in raw_transactions_payload for simplefin_account #{simplefin_account.id} (#{simplefin_account.name}) - #{acct_info}" + return + end + + Rails.logger.info "SimplefinAccount::Transactions::Processor - Processing #{transactions.count} transactions for simplefin_account #{simplefin_account.id} (#{simplefin_account.name}) - #{acct_info}" + + # Log first few transaction IDs for debugging + sample_ids = transactions.first(3).map { |t| t.is_a?(Hash) ? (t[:id] || t["id"]) : nil }.compact + Rails.logger.info "SimplefinAccount::Transactions::Processor - Sample transaction IDs: #{sample_ids.inspect}" + + processed_count = 0 + error_count = 0 # Each entry is processed inside a transaction, but to avoid locking up the DB when # there are hundreds or thousands of transactions, we process them individually. - simplefin_account.raw_transactions_payload.each do |transaction_data| + transactions.each do |transaction_data| SimplefinEntry::Processor.new( transaction_data, simplefin_account: simplefin_account ).process + processed_count += 1 rescue => e - Rails.logger.error "Error processing SimpleFin transaction: #{e.message}" + error_count += 1 + tx_id = transaction_data.is_a?(Hash) ? (transaction_data[:id] || transaction_data["id"]) : nil + Rails.logger.error "SimplefinAccount::Transactions::Processor - Error processing transaction #{tx_id}: #{e.class} - #{e.message}" + Rails.logger.error e.backtrace.first(5).join("\n") if e.backtrace end + + Rails.logger.info "SimplefinAccount::Transactions::Processor - Completed for simplefin_account #{simplefin_account.id}: #{processed_count} processed, #{error_count} errors" end private diff --git a/app/models/simplefin_item.rb b/app/models/simplefin_item.rb index 15d2ea2d5..45acd07b7 100644 --- a/app/models/simplefin_item.rb +++ b/app/models/simplefin_item.rb @@ -57,11 +57,163 @@ class SimplefinItem < ApplicationRecord def process_accounts # Process accounts linked via BOTH legacy FK and AccountProvider - simplefin_accounts.includes(:account, account_provider: :account).each do |simplefin_account| - # Only process if there's a linked account (via either system) - next unless simplefin_account.current_account.present? + # Use direct query to ensure fresh data from DB, bypassing any association cache + all_accounts = SimplefinAccount.where(simplefin_item_id: id).includes(:account, :linked_account, account_provider: :account).to_a + + Rails.logger.info "=" * 60 + Rails.logger.info "SimplefinItem#process_accounts START - Item #{id} (#{name})" + Rails.logger.info " Total SimplefinAccounts: #{all_accounts.count}" + + # Log all accounts for debugging + all_accounts.each do |sfa| + acct = sfa.current_account + Rails.logger.info " - SimplefinAccount id=#{sfa.id} sf_account_id=#{sfa.account_id} name='#{sfa.name}'" + Rails.logger.info " linked_account: #{sfa.linked_account&.id || 'nil'}, account: #{sfa.account&.id || 'nil'}, current_account: #{acct&.id || 'nil'}" + Rails.logger.info " raw_transactions_payload count: #{sfa.raw_transactions_payload.to_a.count}" + end + + # First, try to repair stale linkages (old SimplefinAccount linked but new one has data) + repair_stale_linkages(all_accounts) + + # Re-fetch after repairs - use direct query for fresh data + all_accounts = SimplefinAccount.where(simplefin_item_id: id).includes(:account, :linked_account, account_provider: :account).to_a + + linked = all_accounts.select { |sfa| sfa.current_account.present? } + unlinked = all_accounts.reject { |sfa| sfa.current_account.present? } + + Rails.logger.info "SimplefinItem#process_accounts - After repair: #{linked.count} linked, #{unlinked.count} unlinked" + + # Log unlinked accounts with transactions for debugging + unlinked_with_txns = unlinked.select { |sfa| sfa.raw_transactions_payload.to_a.any? } + if unlinked_with_txns.any? + Rails.logger.warn "SimplefinItem#process_accounts - #{unlinked_with_txns.count} UNLINKED account(s) have transactions that won't be processed:" + unlinked_with_txns.each do |sfa| + Rails.logger.warn " - SimplefinAccount id=#{sfa.id} name='#{sfa.name}' sf_account_id=#{sfa.account_id} txn_count=#{sfa.raw_transactions_payload.to_a.count}" + end + end + + linked.each do |simplefin_account| + acct = simplefin_account.current_account + Rails.logger.info "SimplefinItem#process_accounts - Processing: SimplefinAccount id=#{simplefin_account.id} name='#{simplefin_account.name}' -> Account id=#{acct.id} name='#{acct.name}' type=#{acct.accountable_type}" SimplefinAccount::Processor.new(simplefin_account).process end + + Rails.logger.info "SimplefinItem#process_accounts END" + Rails.logger.info "=" * 60 + end + + # Repairs stale linkages when user re-adds institution in SimpleFIN. + # When a user deletes and re-adds an institution in SimpleFIN, new account IDs are generated. + # This causes old SimplefinAccounts to remain "linked" but stale (no new data), + # while new SimplefinAccounts have data but are unlinked. + # This method detects such cases and transfers the linkage from old to new. + def repair_stale_linkages(all_accounts) + linked = all_accounts.select { |sfa| sfa.current_account.present? } + unlinked = all_accounts.reject { |sfa| sfa.current_account.present? } + + Rails.logger.info "SimplefinItem#repair_stale_linkages - #{linked.count} linked, #{unlinked.count} unlinked SimplefinAccounts" + + # Find unlinked accounts that have transactions + unlinked_with_data = unlinked.select { |sfa| sfa.raw_transactions_payload.to_a.any? } + + if unlinked_with_data.any? + Rails.logger.info "SimplefinItem#repair_stale_linkages - Found #{unlinked_with_data.count} unlinked SimplefinAccount(s) with transactions:" + unlinked_with_data.each do |sfa| + Rails.logger.info " - id=#{sfa.id} name='#{sfa.name}' account_id=#{sfa.account_id} txn_count=#{sfa.raw_transactions_payload.to_a.count}" + end + end + + return if unlinked_with_data.empty? + + # For each unlinked account with data, try to find a matching linked account + unlinked_with_data.each do |new_sfa| + # Find linked SimplefinAccount with same name (case-insensitive). + stale_matches = linked.select do |old_sfa| + old_sfa.name.to_s.downcase.strip == new_sfa.name.to_s.downcase.strip + end + + if stale_matches.size > 1 + Rails.logger.warn "SimplefinItem#repair_stale_linkages - Multiple linked accounts match '#{new_sfa.name}': #{stale_matches.map(&:id).join(', ')}. Using first match." + end + + stale_match = stale_matches.first + next unless stale_match + + account = stale_match.current_account + Rails.logger.info "SimplefinItem#repair_stale_linkages - Found matching accounts:" + Rails.logger.info " - OLD: SimplefinAccount id=#{stale_match.id} account_id=#{stale_match.account_id} txn_count=#{stale_match.raw_transactions_payload.to_a.count}" + Rails.logger.info " - NEW: SimplefinAccount id=#{new_sfa.id} account_id=#{new_sfa.account_id} txn_count=#{new_sfa.raw_transactions_payload.to_a.count}" + Rails.logger.info " - Linked to Account: '#{account.name}' (id=#{account.id})" + + # Transfer the linkage from old to new + begin + # Merge transactions from old to new before transferring + old_transactions = stale_match.raw_transactions_payload.to_a + new_transactions = new_sfa.raw_transactions_payload.to_a + if old_transactions.any? + Rails.logger.info "SimplefinItem#repair_stale_linkages - Merging #{old_transactions.count} transactions from old SimplefinAccount" + merged = merge_transactions(old_transactions, new_transactions) + new_sfa.update!(raw_transactions_payload: merged) + end + + # Check if linked via legacy FK (use to_s for UUID comparison safety) + if account.simplefin_account_id.to_s == stale_match.id.to_s + account.simplefin_account_id = new_sfa.id + account.save! + end + + # Check if linked via AccountProvider + if stale_match.account_provider.present? + Rails.logger.info "SimplefinItem#repair_stale_linkages - Transferring AccountProvider linkage from SimplefinAccount #{stale_match.id} to #{new_sfa.id}" + stale_match.account_provider.update!(provider: new_sfa) + end + + # If the new one doesn't have an AccountProvider yet, create one + new_sfa.ensure_account_provider! + + Rails.logger.info "SimplefinItem#repair_stale_linkages - Successfully transferred linkage for Account '#{account.name}' to SimplefinAccount id=#{new_sfa.id}" + + # Clear transactions from stale SimplefinAccount and leave it orphaned + # We don't destroy it because has_one :account, dependent: :nullify would nullify the FK we just set + # IMPORTANT: Use update_all to bypass AR associations - stale_match.update! would + # trigger autosave on the preloaded account association, reverting the FK we just set! + SimplefinAccount.where(id: stale_match.id).update_all(raw_transactions_payload: [], raw_holdings_payload: []) + Rails.logger.info "SimplefinItem#repair_stale_linkages - Cleared data from stale SimplefinAccount id=#{stale_match.id} (leaving orphaned)" + rescue => e + Rails.logger.error "SimplefinItem#repair_stale_linkages - Failed to transfer linkage: #{e.class} - #{e.message}" + Rails.logger.error e.backtrace.first(5).join("\n") if e.backtrace + end + end + end + + # Merge two arrays of transactions, deduplicating by ID. + # Fallback: uses composite key [posted, amount, description] when ID/fitid missing. + # + # Known edge cases with composite key fallback: + # 1. False positives: Two distinct transactions with identical posted/amount/description + # will be incorrectly merged (rare but possible). + # 2. Type inconsistency: If posted varies in type (String vs Integer), keys won't match. + # 3. Description variations: Minor differences (whitespace, case) prevent matching. + # + # SimpleFIN typically provides transaction IDs, so this fallback is rarely needed. + def merge_transactions(old_txns, new_txns) + by_id = {} + + # Add old transactions first + old_txns.each do |tx| + t = tx.with_indifferent_access + key = t[:id] || t[:fitid] || [ t[:posted], t[:amount], t[:description] ] + by_id[key] = tx + end + + # Add new transactions (overwrite old with same ID) + new_txns.each do |tx| + t = tx.with_indifferent_access + key = t[:id] || t[:fitid] || [ t[:posted], t[:amount], t[:description] ] + by_id[key] = tx + end + + by_id.values end def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil) diff --git a/app/models/simplefin_item/importer.rb b/app/models/simplefin_item/importer.rb index efe93b81b..76ae68390 100644 --- a/app/models/simplefin_item/importer.rb +++ b/app/models/simplefin_item/importer.rb @@ -36,11 +36,11 @@ class SimplefinItem::Importer if simplefin_item.last_synced_at.nil? || no_txns_yet # First sync (or balances-only pre-run) — use chunked approach to get full history - Rails.logger.info "SimplefinItem::Importer - Using chunked history import" + Rails.logger.info "SimplefinItem::Importer - Using CHUNKED HISTORY import (last_synced_at=#{simplefin_item.last_synced_at.inspect}, no_txns_yet=#{no_txns_yet})" import_with_chunked_history else # Regular sync - use single request with buffer - Rails.logger.info "SimplefinItem::Importer - Using regular sync" + Rails.logger.info "SimplefinItem::Importer - Using REGULAR SYNC (last_synced_at=#{simplefin_item.last_synced_at&.strftime('%Y-%m-%d %H:%M')})" import_regular_sync end rescue RateLimitedError => e @@ -331,6 +331,7 @@ class SimplefinItem::Importer # Step 2: Fetch transactions/holdings using the regular window. start_date = determine_sync_start_date + Rails.logger.info "SimplefinItem::Importer - import_regular_sync: last_synced_at=#{simplefin_item.last_synced_at&.strftime('%Y-%m-%d %H:%M')} => start_date=#{start_date&.strftime('%Y-%m-%d')}" accounts_data = fetch_accounts_data(start_date: start_date, pending: true) return if accounts_data.nil? # Error already handled @@ -372,6 +373,7 @@ class SimplefinItem::Importer # # Returns nothing; side-effects are snapshot + account upserts. def perform_account_discovery + Rails.logger.info "SimplefinItem::Importer - perform_account_discovery START (no date params - transactions may be empty)" discovery_data = fetch_accounts_data(start_date: nil) discovered_count = discovery_data&.dig(:accounts)&.size.to_i Rails.logger.info "SimpleFin discovery (no params) returned #{discovered_count} accounts" @@ -547,6 +549,32 @@ class SimplefinItem::Importer transactions = account_data[:transactions] holdings = account_data[:holdings] + # Log detailed info for accounts with holdings (investment accounts) to debug missing transactions + # Note: SimpleFIN doesn't include a 'type' field, so we detect investment accounts by presence of holdings or name + acct_name = account_data[:name].to_s.downcase + has_holdings = holdings.is_a?(Array) && holdings.any? + is_investment = has_holdings || acct_name.include?("ira") || acct_name.include?("401k") || acct_name.include?("retirement") || acct_name.include?("brokerage") + + # Always log for all accounts to trace the import flow + Rails.logger.info "SimplefinItem::Importer#import_account - account_id=#{account_id} name='#{account_data[:name]}' txn_count=#{transactions&.count || 0} holdings_count=#{holdings&.count || 0}" + + if is_investment + Rails.logger.info "SimpleFIN Investment Account Debug - account_id=#{account_id} name='#{account_data[:name]}'" + Rails.logger.info " - API response keys: #{account_data.keys.inspect}" + Rails.logger.info " - transactions count: #{transactions&.count || 0}" + Rails.logger.info " - holdings count: #{holdings&.count || 0}" + Rails.logger.info " - existing raw_transactions_payload count: #{simplefin_account.raw_transactions_payload.to_a.count}" + + # Log transaction data + if transactions.is_a?(Array) && transactions.any? + Rails.logger.info " - Transaction IDs: #{transactions.map { |t| t[:id] || t["id"] }.inspect}" + else + Rails.logger.warn " - NO TRANSACTIONS in API response for investment account!" + # Log what the transactions field actually contains + Rails.logger.info " - transactions raw value: #{account_data[:transactions].inspect}" + end + end + # Update all attributes; only update transactions if present to avoid wiping prior data attrs = { name: account_data[:name], @@ -565,6 +593,8 @@ class SimplefinItem::Importer if transactions.is_a?(Array) && transactions.any? existing_transactions = simplefin_account.raw_transactions_payload.to_a + Rails.logger.info "SimplefinItem::Importer#import_account - Merging transactions for account_id=#{account_id}: #{existing_transactions.count} existing + #{transactions.count} new" + # Build a map of key => best_tx best_by_key = {} @@ -623,6 +653,8 @@ class SimplefinItem::Importer merged_transactions = best_by_key.values attrs[:raw_transactions_payload] = merged_transactions + Rails.logger.info "SimplefinItem::Importer#import_account - Merged result for account_id=#{account_id}: #{merged_transactions.count} total transactions" + # NOTE: Reconciliation disabled - it analyzes the SimpleFin API response # which only contains ~90 days of history, creating misleading "gap" warnings # that don't reflect actual database state. Re-enable if we improve it to @@ -632,6 +664,8 @@ class SimplefinItem::Importer # rescue => e # Rails.logger.warn("SimpleFin: reconciliation failed for sfa=#{simplefin_account.id || account_id}: #{e.class} - #{e.message}") # end + else + Rails.logger.info "SimplefinItem::Importer#import_account - No transactions in API response for account_id=#{account_id} (transactions=#{transactions.inspect.first(100)})" end # Track whether incoming holdings are new/changed so we can materialize and refresh balances @@ -666,6 +700,11 @@ class SimplefinItem::Importer begin simplefin_account.save! + # Log final state after save for debugging + if is_investment + Rails.logger.info "SimplefinItem::Importer#import_account - SAVED account_id=#{account_id}: raw_transactions_payload now has #{simplefin_account.reload.raw_transactions_payload.to_a.count} transactions" + end + # Post-save side effects acct = simplefin_account.current_account if acct @@ -835,15 +874,18 @@ class SimplefinItem::Importer end def initial_sync_lookback_period - # Default to 7 days for initial sync. Providers that support deeper - # history will supply it via chunked fetches, and users can optionally - # set a custom `sync_start_date` to go further back. - 7 + # Default to 60 days for initial sync to capture recent investment + # transactions (dividends, contributions, etc.). Providers that support + # deeper history will supply it via chunked fetches, and users can + # optionally set a custom `sync_start_date` to go further back. + 60 end def sync_buffer_period - # Default to 7 days buffer for subsequent syncs - 7 + # Default to 30 days buffer for subsequent syncs + # Investment accounts often have infrequent transactions (dividends, etc.) + # that would be missed with a shorter window + 30 end # Transaction reconciliation: detect potential data gaps or missing transactions diff --git a/test/models/simplefin_account/transactions/processor_investment_test.rb b/test/models/simplefin_account/transactions/processor_investment_test.rb new file mode 100644 index 000000000..587c3fc01 --- /dev/null +++ b/test/models/simplefin_account/transactions/processor_investment_test.rb @@ -0,0 +1,277 @@ +require "test_helper" + +class SimplefinAccount::Transactions::ProcessorInvestmentTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + + # Create SimpleFIN connection + @simplefin_item = SimplefinItem.create!( + family: @family, + name: "Test SimpleFIN", + access_url: "https://example.com/access" + ) + + # Create an Investment account + @account = Account.create!( + family: @family, + name: "Retirement - Roth IRA", + currency: "USD", + balance: 12199.06, + accountable: Investment.create!(subtype: :roth_ira) + ) + + # Create SimpleFIN account linked to the Investment account + @simplefin_account = SimplefinAccount.create!( + simplefin_item: @simplefin_item, + name: "Roth IRA", + account_id: "ACT-investment-123", + currency: "USD", + account_type: "investment", + current_balance: 12199.06, + raw_transactions_payload: [ + { + "id" => "TRN-921a8cdb-f331-48ee-9de2-b0b9ff1d316a", + "posted" => 1766417520, + "amount" => "1.49", + "description" => "Dividend Reinvestment", + "payee" => "Dividend", + "memo" => "Dividend Reinvestment", + "transacted_at" => 1766417520 + }, + { + "id" => "TRN-881f2417-29e3-43f9-bd1b-013e60ba7a4b", + "posted" => 1766113200, + "amount" => "1.49", + "description" => "Sweep of dividend payouts", + "payee" => "Dividend", + "memo" => "Dividend Payment - IEMG", + "transacted_at" => 1766113200 + }, + { + "id" => "TRN-e52f1326-bbb6-42a7-8148-be48c8a81832", + "posted" => 1765985220, + "amount" => "0.05", + "description" => "Dividend Reinvestment", + "payee" => "Dividend", + "memo" => "Dividend Reinvestment", + "transacted_at" => 1765985220 + } + ] + ) + + # Link the account via legacy FK + @account.update!(simplefin_account_id: @simplefin_account.id) + end + + test "processes dividend transactions for investment accounts" do + assert_equal 0, @account.entries.count, "Should start with no entries" + + # Process transactions + processor = SimplefinAccount::Transactions::Processor.new(@simplefin_account) + processor.process + + # Verify all 3 dividend transactions were created + assert_equal 3, @account.entries.count, "Should create 3 entries for dividend transactions" + + # Verify entries are Transaction type (not Trade) + @account.entries.each do |entry| + assert_equal "Transaction", entry.entryable_type + end + + # Verify external_ids are set correctly + external_ids = @account.entries.pluck(:external_id).sort + expected_ids = [ + "simplefin_TRN-921a8cdb-f331-48ee-9de2-b0b9ff1d316a", + "simplefin_TRN-881f2417-29e3-43f9-bd1b-013e60ba7a4b", + "simplefin_TRN-e52f1326-bbb6-42a7-8148-be48c8a81832" + ].sort + assert_equal expected_ids, external_ids + + # Verify source is simplefin + @account.entries.each do |entry| + assert_equal "simplefin", entry.source + end + end + + test "investment transactions processor is no-op to avoid duplicate processing" do + # First, process with regular processor + SimplefinAccount::Transactions::Processor.new(@simplefin_account).process + initial_count = @account.entries.count + assert_equal 3, initial_count + + # Get the first entry's updated_at before running investment processor + first_entry = @account.entries.first + original_updated_at = first_entry.updated_at + + # Run the investment transactions processor - should be a no-op + SimplefinAccount::Investments::TransactionsProcessor.new(@simplefin_account).process + + # Entry count should be unchanged + assert_equal initial_count, @account.entries.reload.count + + # Entries should not have been modified + first_entry.reload + assert_equal original_updated_at, first_entry.updated_at + end + + test "processes transactions correctly via SimplefinAccount::Processor for investment accounts" do + # Verify the full processor flow works for investment accounts + processor = SimplefinAccount::Processor.new(@simplefin_account) + processor.process + + # Should create transaction entries + assert_equal 3, @account.entries.where(entryable_type: "Transaction").count + + # Verify amounts are correctly negated (SimpleFIN positive = income = negative in Sure) + entry = @account.entries.find_by(external_id: "simplefin_TRN-921a8cdb-f331-48ee-9de2-b0b9ff1d316a") + assert_not_nil entry + assert_equal BigDecimal("-1.49"), entry.amount + end + + test "logs appropriate messages during processing" do + # Capture log output + log_output = StringIO.new + original_logger = Rails.logger + Rails.logger = Logger.new(log_output) + + SimplefinAccount::Transactions::Processor.new(@simplefin_account).process + + Rails.logger = original_logger + log_content = log_output.string + + # Should log start message with transaction count + assert_match(/Processing 3 transactions/, log_content) + + # Should log completion message + assert_match(/Completed.*3 processed, 0 errors/, log_content) + end + + test "handles empty raw_transactions_payload gracefully" do + @simplefin_account.update!(raw_transactions_payload: []) + + # Should not raise an error + processor = SimplefinAccount::Transactions::Processor.new(@simplefin_account) + processor.process + + assert_equal 0, @account.entries.count + end + + test "handles nil raw_transactions_payload gracefully" do + @simplefin_account.update!(raw_transactions_payload: nil) + + # Should not raise an error + processor = SimplefinAccount::Transactions::Processor.new(@simplefin_account) + processor.process + + assert_equal 0, @account.entries.count + end + + test "repairs stale linkage when user re-adds institution in SimpleFIN" do + # Simulate user re-adding institution: old SimplefinAccount is linked but has no transactions, + # new SimplefinAccount is unlinked but has transactions + + # Make the original account "stale" (no transactions) + @simplefin_account.update!(raw_transactions_payload: []) + + # Create a "new" SimplefinAccount with the same name but different account_id + # This simulates what happens when SimpleFIN generates new IDs after re-adding + new_simplefin_account = SimplefinAccount.create!( + simplefin_item: @simplefin_item, + name: "Roth IRA", # Same name as original + account_id: "ACT-investment-456-NEW", # New ID + currency: "USD", + account_type: "investment", + current_balance: 12199.06, + raw_transactions_payload: [ + { + "id" => "TRN-new-transaction-001", + "posted" => 1766417520, + "amount" => "5.00", + "description" => "New Dividend", + "payee" => "Dividend", + "memo" => "New Dividend Payment" + } + ] + ) + # New account is NOT linked (this is the problem we're fixing) + assert_nil new_simplefin_account.account + + # Before repair: @simplefin_account is linked (but stale), new_simplefin_account is unlinked + assert_equal @simplefin_account.id, @account.reload.simplefin_account_id + + # Process accounts - should repair the stale linkage + @simplefin_item.process_accounts + + # After repair: new_simplefin_account should be linked + @account.reload + assert_equal new_simplefin_account.id, @account.simplefin_account_id, "Expected linkage to transfer to new_simplefin_account (#{new_simplefin_account.id}) but got #{@account.simplefin_account_id}" + + # Old SimplefinAccount should still exist but be cleared of data + @simplefin_account.reload + assert_equal [], @simplefin_account.raw_transactions_payload + + # Transaction from new SimplefinAccount should be created + assert_equal 1, @account.entries.count + entry = @account.entries.first + assert_equal "simplefin_TRN-new-transaction-001", entry.external_id + assert_equal BigDecimal("-5.00"), entry.amount + end + + test "does not repair linkage when names dont match" do + # Make original stale + @simplefin_account.update!(raw_transactions_payload: []) + + # Create new with DIFFERENT name + new_simplefin_account = SimplefinAccount.create!( + simplefin_item: @simplefin_item, + name: "Different Account Name", # Different name + account_id: "ACT-different-456", + currency: "USD", + account_type: "investment", + current_balance: 1000.00, + raw_transactions_payload: [ + { "id" => "TRN-different", "posted" => 1766417520, "amount" => "10.00", "description" => "Test" } + ] + ) + + original_linkage = @account.simplefin_account_id + + @simplefin_item.process_accounts + + # Should NOT have transferred linkage because names don't match + @account.reload + assert_equal original_linkage, @account.simplefin_account_id + assert_equal 0, @account.entries.count + end + + test "repairs linkage and merges transactions when both old and new have data" do + # Both accounts have transactions - repair should still happen and merge them + assert @simplefin_account.raw_transactions_payload.any? + + # Create new with same name + new_simplefin_account = SimplefinAccount.create!( + simplefin_item: @simplefin_item, + name: "Roth IRA", + account_id: "ACT-investment-456-NEW", + currency: "USD", + account_type: "investment", + current_balance: 12199.06, + raw_transactions_payload: [ + { "id" => "TRN-new", "posted" => 1766417520, "amount" => "5.00", "description" => "New" } + ] + ) + + @simplefin_item.process_accounts + + # Should transfer linkage to new account (repair by name match) + @account.reload + assert_equal new_simplefin_account.id, @account.simplefin_account_id + + # Transactions should be merged: 3 from old + 1 from new = 4 total + assert_equal 4, @account.entries.count + + # Old account should be cleared + @simplefin_account.reload + assert_equal [], @simplefin_account.raw_transactions_payload + end +end