diff --git a/app/controllers/transfer_matches_controller.rb b/app/controllers/transfer_matches_controller.rb index 37dbc9d7e..e07aa5614 100644 --- a/app/controllers/transfer_matches_controller.rb +++ b/app/controllers/transfer_matches_controller.rb @@ -10,7 +10,18 @@ class TransferMatchesController < ApplicationController @transfer = build_transfer Transfer.transaction do @transfer.save! - @transfer.outflow_transaction.update!(kind: Transfer.kind_for_account(@transfer.outflow_transaction.entry.account)) + + # Use DESTINATION (inflow) account for kind, matching Transfer::Creator logic + destination_account = @transfer.inflow_transaction.entry.account + outflow_kind = Transfer.kind_for_account(destination_account) + outflow_attrs = { kind: outflow_kind } + + if outflow_kind == "investment_contribution" + category = destination_account.family.investment_contributions_category + outflow_attrs[:category] = category if category.present? && @transfer.outflow_transaction.category_id.blank? + end + + @transfer.outflow_transaction.update!(outflow_attrs) @transfer.inflow_transaction.update!(kind: "funds_movement") end diff --git a/app/models/family.rb b/app/models/family.rb index 6741393ca..e4fef288b 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -106,9 +106,15 @@ class Family < ApplicationRecord @income_statement ||= IncomeStatement.new(self) end - # Returns the Investment Contributions category for this family, or nil if not found. - # This is a bootstrapped category used for auto-categorizing transfers to investment accounts. + # Returns the Investment Contributions category for this family, creating it if it doesn't exist. + # This is used for auto-categorizing transfers to investment accounts. def investment_contributions_category + categories.find_or_create_by!(name: Category.investment_contributions_name) do |cat| + cat.color = "#0d9488" + cat.classification = "expense" + cat.lucide_icon = "trending-up" + end + rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid categories.find_by(name: Category.investment_contributions_name) end diff --git a/app/models/family/auto_transfer_matchable.rb b/app/models/family/auto_transfer_matchable.rb index 60f583360..a53cca846 100644 --- a/app/models/family/auto_transfer_matchable.rb +++ b/app/models/family/auto_transfer_matchable.rb @@ -76,6 +76,16 @@ module Family::AutoTransferMatchable inflow_transaction.update!(kind: "funds_movement") outflow_transaction.update!(kind: Transfer.kind_for_account(inflow_transaction.entry.account)) + # Assign Investment Contributions category for transfers to investment accounts + destination_account = Transaction.find(match.inflow_transaction_id).entry.account + if Transfer.kind_for_account(destination_account) == "investment_contribution" + outflow_txn = Transaction.find(match.outflow_transaction_id) + if outflow_txn.category_id.blank? + category = destination_account.family.investment_contributions_category + outflow_txn.update!(category: category) if category.present? + end + end + used_transaction_ids << match.inflow_transaction_id used_transaction_ids << match.outflow_transaction_id end diff --git a/app/models/plaid_account/investments/transactions_processor.rb b/app/models/plaid_account/investments/transactions_processor.rb index efce85118..d683d4ec7 100644 --- a/app/models/plaid_account/investments/transactions_processor.rb +++ b/app/models/plaid_account/investments/transactions_processor.rb @@ -46,7 +46,7 @@ class PlaidAccount::Investments::TransactionsProcessor end def cash_transaction?(transaction) - transaction["type"] == "cash" || transaction["type"] == "fee" || transaction["type"] == "transfer" + %w[cash fee transfer contribution withdrawal].include?(transaction["type"]) end def find_or_create_trade_entry(transaction) diff --git a/app/models/rule/action_executor/set_as_transfer_or_payment.rb b/app/models/rule/action_executor/set_as_transfer_or_payment.rb index ad2d825fa..d4e1939dc 100644 --- a/app/models/rule/action_executor/set_as_transfer_or_payment.rb +++ b/app/models/rule/action_executor/set_as_transfer_or_payment.rb @@ -18,7 +18,18 @@ class Rule::ActionExecutor::SetAsTransferOrPayment < Rule::ActionExecutor transfer = build_transfer(target_account, entry) Transfer.transaction do transfer.save! - transfer.outflow_transaction.update!(kind: Transfer.kind_for_account(transfer.outflow_transaction.entry.account)) + + # Use DESTINATION (inflow) account for kind, matching Transfer::Creator logic + destination_account = transfer.inflow_transaction.entry.account + outflow_kind = Transfer.kind_for_account(destination_account) + outflow_attrs = { kind: outflow_kind } + + if outflow_kind == "investment_contribution" + category = destination_account.family.investment_contributions_category + outflow_attrs[:category] = category if category.present? && transfer.outflow_transaction.category_id.blank? + end + + transfer.outflow_transaction.update!(outflow_attrs) transfer.inflow_transaction.update!(kind: "funds_movement") end diff --git a/test/controllers/transfer_matches_controller_test.rb b/test/controllers/transfer_matches_controller_test.rb index aff23f4f3..51ba79a23 100644 --- a/test/controllers/transfer_matches_controller_test.rb +++ b/test/controllers/transfer_matches_controller_test.rb @@ -39,4 +39,24 @@ class TransferMatchesControllerTest < ActionDispatch::IntegrationTest assert_redirected_to transactions_url assert_equal "Transfer created", flash[:notice] end + + test "assigns investment_contribution kind and category for investment destination" do + # Outflow from depository (positive amount), target is investment + outflow_entry = create_transaction(amount: 100, account: accounts(:depository)) + + post transaction_transfer_match_path(outflow_entry), params: { + transfer_match: { + method: "new", + target_account_id: accounts(:investment).id + } + } + + outflow_entry.reload + outflow_txn = outflow_entry.entryable + + assert_equal "investment_contribution", outflow_txn.kind + + category = @user.family.investment_contributions_category + assert_equal category, outflow_txn.category + end end diff --git a/test/models/family/auto_transfer_matchable_test.rb b/test/models/family/auto_transfer_matchable_test.rb index 41955f7eb..3a8e09df3 100644 --- a/test/models/family/auto_transfer_matchable_test.rb +++ b/test/models/family/auto_transfer_matchable_test.rb @@ -109,6 +109,19 @@ class Family::AutoTransferMatchableTest < ActiveSupport::TestCase end end + test "auto-matched cash to investment assigns investment contribution category" do + investment = accounts(:investment) + outflow_entry = create_transaction(date: Date.current, account: @depository, amount: 500) + inflow_entry = create_transaction(date: Date.current, account: investment, amount: -500) + + @family.auto_match_transfers! + + outflow_entry.reload + + category = @family.investment_contributions_category + assert_equal category, outflow_entry.entryable.category + end + test "does not match multi-currency transfer with missing exchange rate" do create_transaction(date: Date.current, account: @depository, amount: 500) create_transaction(date: Date.current, account: @credit_card, amount: -700, currency: "GBP") diff --git a/test/models/family_test.rb b/test/models/family_test.rb index cf5b800a8..75761fb2e 100644 --- a/test/models/family_test.rb +++ b/test/models/family_test.rb @@ -7,6 +7,35 @@ class FamilyTest < ActiveSupport::TestCase @syncable = families(:dylan_family) end + test "investment_contributions_category creates category when missing" do + family = families(:dylan_family) + family.categories.where(name: Category.investment_contributions_name).destroy_all + + assert_nil family.categories.find_by(name: Category.investment_contributions_name) + + category = family.investment_contributions_category + + assert category.persisted? + assert_equal Category.investment_contributions_name, category.name + assert_equal "#0d9488", category.color + assert_equal "expense", category.classification + assert_equal "trending-up", category.lucide_icon + end + + test "investment_contributions_category returns existing category" do + family = families(:dylan_family) + existing = family.categories.find_or_create_by!(name: Category.investment_contributions_name) do |c| + c.color = "#0d9488" + c.classification = "expense" + c.lucide_icon = "trending-up" + end + + assert_no_difference "Category.count" do + result = family.investment_contributions_category + assert_equal existing, result + end + end + test "available_merchants includes family merchants without transactions" do family = families(:dylan_family) diff --git a/test/models/plaid_account/investments/transactions_processor_test.rb b/test/models/plaid_account/investments/transactions_processor_test.rb index 648d22cf4..e54518fd1 100644 --- a/test/models/plaid_account/investments/transactions_processor_test.rb +++ b/test/models/plaid_account/investments/transactions_processor_test.rb @@ -149,6 +149,70 @@ class PlaidAccount::Investments::TransactionsProcessorTest < ActiveSupport::Test assert_equal -1, entry.trade.qty end + test "creates contribution transactions as cash transactions" do + test_investments_payload = { + transactions: [ + { + "investment_transaction_id" => "contrib_123", + "type" => "contribution", + "amount" => -500.0, + "iso_currency_code" => "USD", + "date" => Date.current, + "name" => "401k Contribution" + } + ] + } + + @plaid_account.update!(raw_holdings_payload: test_investments_payload) + + @security_resolver.expects(:resolve).never + + processor = PlaidAccount::Investments::TransactionsProcessor.new(@plaid_account, security_resolver: @security_resolver) + + assert_difference [ "Entry.count", "Transaction.count" ], 1 do + processor.process + end + + entry = Entry.order(created_at: :desc).first + + assert_equal(-500.0, entry.amount) + assert_equal "USD", entry.currency + assert_equal "401k Contribution", entry.name + assert_instance_of Transaction, entry.entryable + end + + test "creates withdrawal transactions as cash transactions" do + test_investments_payload = { + transactions: [ + { + "investment_transaction_id" => "withdraw_123", + "type" => "withdrawal", + "amount" => 1000.0, + "iso_currency_code" => "USD", + "date" => Date.current, + "name" => "IRA Withdrawal" + } + ] + } + + @plaid_account.update!(raw_holdings_payload: test_investments_payload) + + @security_resolver.expects(:resolve).never + + processor = PlaidAccount::Investments::TransactionsProcessor.new(@plaid_account, security_resolver: @security_resolver) + + assert_difference [ "Entry.count", "Transaction.count" ], 1 do + processor.process + end + + entry = Entry.order(created_at: :desc).first + + assert_equal 1000.0, entry.amount + assert_equal "USD", entry.currency + assert_equal "IRA Withdrawal", entry.name + assert_instance_of Transaction, entry.entryable + end + test "creates transfer transactions as cash transactions" do test_investments_payload = { transactions: [ diff --git a/test/models/rule/action_test.rb b/test/models/rule/action_test.rb index c437e558b..c6370653a 100644 --- a/test/models/rule/action_test.rb +++ b/test/models/rule/action_test.rb @@ -165,6 +165,30 @@ class Rule::ActionTest < ActiveSupport::TestCase end end + test "set_as_transfer_or_payment assigns investment_contribution kind and category for investment destination" do + investment = accounts(:investment) + + action = Rule::Action.new( + rule: @transaction_rule, + action_type: "set_as_transfer_or_payment", + value: investment.id + ) + + # Only apply to txn1 (positive amount = outflow) + action.apply(Transaction.where(id: @txn1.id)) + + @txn1.reload + + transfer = Transfer.find_by(outflow_transaction_id: @txn1.id) || Transfer.find_by(inflow_transaction_id: @txn1.id) + assert transfer.present?, "Transfer should be created" + + assert_equal "investment_contribution", transfer.outflow_transaction.kind + assert_equal "funds_movement", transfer.inflow_transaction.kind + + category = @family.investment_contributions_category + assert_equal category, transfer.outflow_transaction.category + end + test "set_investment_activity_label ignores invalid values" do action = Rule::Action.new( rule: @transaction_rule,