diff --git a/app/components/UI/account/activity_feed.html.erb b/app/components/UI/account/activity_feed.html.erb index 14c9b36e4..fe5041f13 100644 --- a/app/components/UI/account/activity_feed.html.erb +++ b/app/components/UI/account/activity_feed.html.erb @@ -32,6 +32,13 @@ data: { turbo_frame: :modal }) %> <% end %> <% end %> + + <% menu.with_item( + variant: "link", + text: t("accounts.show.activity.new_transfer"), + icon: "arrow-right-left", + href: new_transfer_path(from_account_id: account.id), + data: { turbo_frame: :modal }) %> <% end %> diff --git a/app/controllers/transfers_controller.rb b/app/controllers/transfers_controller.rb index 7342414bb..db4343584 100644 --- a/app/controllers/transfers_controller.rb +++ b/app/controllers/transfers_controller.rb @@ -5,6 +5,7 @@ class TransfersController < ApplicationController def new @transfer = Transfer.new + @from_account_id = params[:from_account_id] end def show diff --git a/app/models/account/provider_import_adapter.rb b/app/models/account/provider_import_adapter.rb index 932edf57f..64ba64c81 100644 --- a/app/models/account/provider_import_adapter.rb +++ b/app/models/account/provider_import_adapter.rb @@ -150,13 +150,15 @@ class Account::ProviderImportAdapter # Auto-set kind for internal movements and contributions auto_kind = nil + auto_category = nil if Transaction::INTERNAL_MOVEMENT_LABELS.include?(detected_label) auto_kind = "funds_movement" elsif detected_label == "Contribution" auto_kind = "investment_contribution" + auto_category = account.family.investment_contributions_category end - # Set investment activity label and kind if detected + # Set investment activity label, kind, and category if detected if entry.entryable.is_a?(Transaction) if detected_label.present? && entry.transaction.investment_activity_label.blank? entry.transaction.assign_attributes(investment_activity_label: detected_label) @@ -165,6 +167,10 @@ class Account::ProviderImportAdapter if auto_kind.present? entry.transaction.assign_attributes(kind: auto_kind) end + + if auto_category.present? && entry.transaction.category_id.blank? + entry.transaction.assign_attributes(category: auto_category) + end end entry.save! diff --git a/app/models/category.rb b/app/models/category.rb index b6586492e..9a83488fc 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -35,9 +35,10 @@ class Category < ApplicationRecord PAYMENT_COLOR = "#db5a54" TRADE_COLOR = "#e99537" - # Synthetic category name keys for i18n + # Category name keys for i18n UNCATEGORIZED_NAME_KEY = "models.category.uncategorized" OTHER_INVESTMENTS_NAME_KEY = "models.category.other_investments" + INVESTMENT_CONTRIBUTIONS_NAME_KEY = "models.category.investment_contributions" class Group attr_reader :category, :subcategories @@ -113,6 +114,11 @@ class Category < ApplicationRecord I18n.t(OTHER_INVESTMENTS_NAME_KEY) end + # Helper to get the localized name for investment contributions + def investment_contributions_name + I18n.t(INVESTMENT_CONTRIBUTIONS_NAME_KEY) + end + private def default_categories [ @@ -137,7 +143,7 @@ class Category < ApplicationRecord [ "Services", "#7c3aed", "briefcase", "expense" ], [ "Fees", "#6b7280", "receipt", "expense" ], [ "Savings & Investments", "#059669", "piggy-bank", "expense" ], - [ "Investment Contributions", "#0d9488", "trending-up", "expense" ] + [ investment_contributions_name, "#0d9488", "trending-up", "expense" ] ] end end diff --git a/app/models/family.rb b/app/models/family.rb index 8e48ced1c..ac3d3da41 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -79,6 +79,12 @@ 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. + def investment_contributions_category + categories.find_by(name: Category.investment_contributions_name) + end + def investment_statement @investment_statement ||= InvestmentStatement.new(self) end diff --git a/app/models/income_statement/category_stats.rb b/app/models/income_statement/category_stats.rb index f4eb2815f..0552ebc62 100644 --- a/app/models/income_statement/category_stats.rb +++ b/app/models/income_statement/category_stats.rb @@ -35,8 +35,8 @@ class IncomeStatement::CategoryStats SELECT c.id as category_id, date_trunc(:interval, ae.date) as period, - CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, - SUM(ae.amount * COALESCE(er.rate, 1)) as total + CASE WHEN t.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, + SUM(CASE WHEN t.kind = 'investment_contribution' THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END) as total FROM transactions t JOIN entries ae ON ae.entryable_id = t.id AND ae.entryable_type = 'Transaction' JOIN accounts a ON a.id = ae.account_id @@ -47,11 +47,11 @@ class IncomeStatement::CategoryStats er.to_currency = :target_currency ) WHERE a.family_id = :family_id - AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment', 'investment_contribution') + AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment') AND ae.excluded = false AND (t.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true AND (t.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true - GROUP BY c.id, period, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END + GROUP BY c.id, period, CASE WHEN t.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END ) SELECT category_id, diff --git a/app/models/income_statement/family_stats.rb b/app/models/income_statement/family_stats.rb index 48b0d9507..bfaae6fa5 100644 --- a/app/models/income_statement/family_stats.rb +++ b/app/models/income_statement/family_stats.rb @@ -33,8 +33,8 @@ class IncomeStatement::FamilyStats WITH period_totals AS ( SELECT date_trunc(:interval, ae.date) as period, - CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, - SUM(ae.amount * COALESCE(er.rate, 1)) as total + CASE WHEN t.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, + SUM(CASE WHEN t.kind = 'investment_contribution' THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END) as total FROM transactions t JOIN entries ae ON ae.entryable_id = t.id AND ae.entryable_type = 'Transaction' JOIN accounts a ON a.id = ae.account_id @@ -44,11 +44,11 @@ class IncomeStatement::FamilyStats er.to_currency = :target_currency ) WHERE a.family_id = :family_id - AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment', 'investment_contribution') + AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment') AND ae.excluded = false AND (t.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true AND (t.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true - GROUP BY period, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END + GROUP BY period, CASE WHEN t.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END ) SELECT classification, diff --git a/app/models/income_statement/totals.rb b/app/models/income_statement/totals.rb index ffcb468b1..7bb9f67dc 100644 --- a/app/models/income_statement/totals.rb +++ b/app/models/income_statement/totals.rb @@ -56,8 +56,8 @@ class IncomeStatement::Totals SELECT c.id as category_id, c.parent_id as parent_category_id, - CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, - ABS(SUM(ae.amount * COALESCE(er.rate, 1))) as total, + CASE WHEN at.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, + ABS(SUM(CASE WHEN at.kind = 'investment_contribution' THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END)) as total, COUNT(ae.id) as transactions_count, false as is_uncategorized_investment FROM (#{@transactions_scope.to_sql}) at @@ -69,11 +69,11 @@ class IncomeStatement::Totals er.from_currency = ae.currency AND er.to_currency = :target_currency ) - WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment', 'investment_contribution') + WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment') AND ae.excluded = false AND a.family_id = :family_id AND a.status IN ('draft', 'active') - GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END; + GROUP BY c.id, c.parent_id, CASE WHEN at.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END; SQL end @@ -82,8 +82,8 @@ class IncomeStatement::Totals SELECT c.id as category_id, c.parent_id as parent_category_id, - CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, - ABS(SUM(ae.amount * COALESCE(er.rate, 1))) as total, + CASE WHEN at.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, + ABS(SUM(CASE WHEN at.kind = 'investment_contribution' THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END)) as total, COUNT(ae.id) as entry_count, false as is_uncategorized_investment FROM (#{@transactions_scope.to_sql}) at @@ -95,7 +95,7 @@ class IncomeStatement::Totals er.from_currency = ae.currency AND er.to_currency = :target_currency ) - WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment', 'investment_contribution') + WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment') AND ( at.investment_activity_label IS NULL OR at.investment_activity_label NOT IN ('Transfer', 'Sweep In', 'Sweep Out', 'Exchange') @@ -103,7 +103,7 @@ class IncomeStatement::Totals AND ae.excluded = false AND a.family_id = :family_id AND a.status IN ('draft', 'active') - GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END + GROUP BY c.id, c.parent_id, CASE WHEN at.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END SQL end diff --git a/app/models/transaction/search.rb b/app/models/transaction/search.rb index e46a66472..b4311415f 100644 --- a/app/models/transaction/search.rb +++ b/app/models/transaction/search.rb @@ -49,8 +49,8 @@ class Transaction::Search Rails.cache.fetch("transaction_search_totals/#{cache_key_base}") do result = transactions_scope .select( - "COALESCE(SUM(CASE WHEN entries.amount >= 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment', 'investment_contribution') THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as expense_total", - "COALESCE(SUM(CASE WHEN entries.amount < 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment', 'investment_contribution') THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as income_total", + "COALESCE(SUM(CASE WHEN transactions.kind = 'investment_contribution' THEN ABS(entries.amount * COALESCE(er.rate, 1)) WHEN entries.amount >= 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment') THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as expense_total", + "COALESCE(SUM(CASE WHEN entries.amount < 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment', 'investment_contribution') THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as income_total", "COUNT(entries.id) as transactions_count" ) .joins( @@ -100,14 +100,14 @@ class Transaction::Search if parent_category_ids.empty? query = query.left_joins(:category).where( "categories.name IN (?) OR ( - categories.id IS NULL AND (transactions.kind NOT IN ('funds_movement', 'cc_payment', 'investment_contribution')) + categories.id IS NULL AND (transactions.kind NOT IN ('funds_movement', 'cc_payment')) )", categories ) else query = query.left_joins(:category).where( "categories.name IN (?) OR categories.parent_id IN (?) OR ( - categories.id IS NULL AND (transactions.kind NOT IN ('funds_movement', 'cc_payment', 'investment_contribution')) + categories.id IS NULL AND (transactions.kind NOT IN ('funds_movement', 'cc_payment')) )", categories, parent_category_ids ) @@ -125,8 +125,11 @@ class Transaction::Search return query if types.sort == [ "expense", "income", "transfer" ] transfer_condition = "transactions.kind IN ('funds_movement', 'cc_payment', 'loan_payment')" - expense_condition = "entries.amount >= 0" - income_condition = "entries.amount <= 0" + # investment_contribution is always an expense regardless of amount sign + # (handles both manual outflows and provider-imported inflows like 401k contributions) + investment_contribution_condition = "transactions.kind = 'investment_contribution'" + expense_condition = "(entries.amount >= 0 OR #{investment_contribution_condition})" + income_condition = "(entries.amount <= 0 AND NOT #{investment_contribution_condition})" condition = case types.sort when [ "transfer" ] diff --git a/app/models/transfer/creator.rb b/app/models/transfer/creator.rb index 8ea93a9ce..1825e92d0 100644 --- a/app/models/transfer/creator.rb +++ b/app/models/transfer/creator.rb @@ -27,18 +27,25 @@ class Transfer::Creator def outflow_transaction name = "#{name_prefix} to #{destination_account.name}" + kind = outflow_transaction_kind Transaction.new( - kind: outflow_transaction_kind, + kind: kind, + category: (investment_contributions_category if kind == "investment_contribution"), entry: source_account.entries.build( amount: amount.abs, currency: source_account.currency, date: date, name: name, + user_modified: true, # Protect from provider sync claiming this entry ) ) end + def investment_contributions_category + source_account.family.investment_contributions_category + end + def inflow_transaction name = "#{name_prefix} from #{source_account.name}" @@ -49,6 +56,7 @@ class Transfer::Creator currency: destination_account.currency, date: date, name: name, + user_modified: true, # Protect from provider sync claiming this entry ) ) end @@ -70,11 +78,21 @@ class Transfer::Creator "loan_payment" elsif destination_account.liability? "cc_payment" + elsif destination_is_investment? && !source_is_investment? + "investment_contribution" else "funds_movement" end end + def destination_is_investment? + destination_account.investment? || destination_account.crypto? + end + + def source_is_investment? + source_account.investment? || source_account.crypto? + end + def name_prefix if destination_account.liability? "Payment" diff --git a/app/views/transfers/_form.html.erb b/app/views/transfers/_form.html.erb index d584cb7c2..4158303e4 100644 --- a/app/views/transfers/_form.html.erb +++ b/app/views/transfers/_form.html.erb @@ -11,8 +11,8 @@
- <%= f.collection_select :from_account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".from") }, required: true %> - <%= f.collection_select :to_account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".to") }, required: true %> + <%= f.collection_select :from_account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".from"), selected: @from_account_id }, required: true %> + <%= f.collection_select :to_account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".to") }, required: true %> <%= f.number_field :amount, label: t(".amount"), required: true, min: 0, placeholder: "100", step: 0.00000001 %> <%= f.date_field :date, value: transfer.inflow_transaction&.entry&.date || Date.current, label: t(".date"), required: true, max: Date.current %>
diff --git a/config/locales/models/category/ca.yml b/config/locales/models/category/ca.yml index 6d1980a6c..310293577 100644 --- a/config/locales/models/category/ca.yml +++ b/config/locales/models/category/ca.yml @@ -4,3 +4,4 @@ ca: category: other_investments: Altres inversions uncategorized: Sense categoria + investment_contributions: Contribucions d'inversió diff --git a/config/locales/models/category/en.yml b/config/locales/models/category/en.yml index dc86eba56..749f80860 100644 --- a/config/locales/models/category/en.yml +++ b/config/locales/models/category/en.yml @@ -4,3 +4,4 @@ en: category: uncategorized: Uncategorized other_investments: Other Investments + investment_contributions: Investment Contributions diff --git a/config/locales/models/category/fr.yml b/config/locales/models/category/fr.yml index 2c9a907a2..eef81bda0 100644 --- a/config/locales/models/category/fr.yml +++ b/config/locales/models/category/fr.yml @@ -4,3 +4,4 @@ fr: category: uncategorized: Non catégorisé other_investments: Autres investissements + investment_contributions: Contributions aux investissements diff --git a/config/locales/models/category/nl.yml b/config/locales/models/category/nl.yml index 9428ab9cf..63edada65 100644 --- a/config/locales/models/category/nl.yml +++ b/config/locales/models/category/nl.yml @@ -4,3 +4,4 @@ nl: category: uncategorized: Ongecategoriseerd other_investments: Overige beleggingen + investment_contributions: Investeringsbijdragen diff --git a/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml index 939d175af..71b5a33fb 100644 --- a/config/locales/views/accounts/en.yml +++ b/config/locales/views/accounts/en.yml @@ -59,6 +59,7 @@ en: new_activity: New activity new_balance: New balance new_transaction: New transaction + new_transfer: New transfer no_entries: No entries found pending: Pending search: diff --git a/test/models/income_statement_test.rb b/test/models/income_statement_test.rb index e4af61d66..7231626a2 100644 --- a/test/models/income_statement_test.rb +++ b/test/models/income_statement_test.rb @@ -286,7 +286,7 @@ class IncomeStatementTest < ActiveSupport::TestCase assert_equal Money.new(1050, @family.currency), totals.expense_money # 900 + 150 end - test "excludes investment_contribution transactions from income statement" do + test "includes investment_contribution transactions as expenses in income statement" do # Create a transfer to investment account (marked as investment_contribution) investment_contribution = create_transaction( account: @checking_account, @@ -298,9 +298,39 @@ class IncomeStatementTest < ActiveSupport::TestCase income_statement = IncomeStatement.new(@family) totals = income_statement.totals(date_range: Period.last_30_days.date_range) - # investment_contribution should be excluded (it's in the exclusion list) - assert_equal 4, totals.transactions_count # Only original 4 transactions + # investment_contribution should be included as an expense (visible in cashflow) + assert_equal 5, totals.transactions_count # Original 4 + investment_contribution assert_equal Money.new(1000, @family.currency), totals.income_money - assert_equal Money.new(900, @family.currency), totals.expense_money + assert_equal Money.new(1900, @family.currency), totals.expense_money # 900 + 1000 investment + end + + test "includes provider-imported investment_contribution inflows as expenses" do + # Simulates a 401k contribution that was auto-deducted from payroll + # Provider imports this as an inflow to the investment account (negative amount) + # but it should still appear as an expense in cashflow + + investment_account = @family.accounts.create!( + name: "401k", + currency: @family.currency, + balance: 10000, + accountable: Investment.new + ) + + # Provider-imported contribution shows as inflow (negative amount) to the investment account + # kind is investment_contribution, which should be treated as expense regardless of sign + provider_contribution = create_transaction( + account: investment_account, + amount: -500, # Negative = inflow to account + category: nil, + kind: "investment_contribution" + ) + + income_statement = IncomeStatement.new(@family) + totals = income_statement.totals(date_range: Period.last_30_days.date_range) + + # The provider-imported contribution should appear as an expense + assert_equal 5, totals.transactions_count # Original 4 + provider contribution + assert_equal Money.new(1000, @family.currency), totals.income_money + assert_equal Money.new(1400, @family.currency), totals.expense_money # 900 + 500 (abs of -500) end end diff --git a/test/models/transfer/creator_test.rb b/test/models/transfer/creator_test.rb index f6d9379ba..4f1a2a163 100644 --- a/test/models/transfer/creator_test.rb +++ b/test/models/transfer/creator_test.rb @@ -7,9 +7,11 @@ class Transfer::CreatorTest < ActiveSupport::TestCase @destination_account = accounts(:investment) @date = Date.current @amount = 100 + # Ensure the Investment Contributions category exists for transfer tests + @investment_category = ensure_investment_contributions_category(@family) end - test "creates basic transfer" do + test "creates investment contribution when transferring from depository to investment" do creator = Transfer::Creator.new( family: @family, source_account_id: @source_account.id, @@ -22,17 +24,16 @@ class Transfer::CreatorTest < ActiveSupport::TestCase assert transfer.persisted? assert_equal "confirmed", transfer.status - assert transfer.regular_transfer? - assert_equal "transfer", transfer.transfer_type - # Verify outflow transaction (from source account) + # Verify outflow transaction is marked as investment_contribution outflow = transfer.outflow_transaction - assert_equal "funds_movement", outflow.kind + assert_equal "investment_contribution", outflow.kind assert_equal @amount, outflow.entry.amount assert_equal @source_account.currency, outflow.entry.currency assert_equal "Transfer to #{@destination_account.name}", outflow.entry.name + assert_equal @investment_category, outflow.category, "Should auto-assign Investment Contributions category" - # Verify inflow transaction (to destination account) + # Verify inflow transaction (always funds_movement) inflow = transfer.inflow_transaction assert_equal "funds_movement", inflow.kind assert_equal(@amount * -1, inflow.entry.amount) @@ -40,8 +41,35 @@ class Transfer::CreatorTest < ActiveSupport::TestCase assert_equal "Transfer from #{@source_account.name}", inflow.entry.name end - test "creates multi-currency transfer" do - # Use crypto account which has USD currency but different from source + test "creates basic transfer between depository accounts" do + other_depository = @family.accounts.create!(name: "Savings", balance: 1000, currency: "USD", accountable: Depository.new) + + creator = Transfer::Creator.new( + family: @family, + source_account_id: @source_account.id, + destination_account_id: other_depository.id, + date: @date, + amount: @amount + ) + + transfer = creator.create + + assert transfer.persisted? + assert_equal "confirmed", transfer.status + assert transfer.regular_transfer? + assert_equal "transfer", transfer.transfer_type + + # Verify outflow transaction (depository to depository = funds_movement) + outflow = transfer.outflow_transaction + assert_equal "funds_movement", outflow.kind + assert_nil outflow.category, "Should NOT auto-assign category for regular transfers" + + # Verify inflow transaction + inflow = transfer.inflow_transaction + assert_equal "funds_movement", inflow.kind + end + + test "creates investment contribution when transferring from depository to crypto" do crypto_account = accounts(:crypto) creator = Transfer::Creator.new( @@ -55,13 +83,12 @@ class Transfer::CreatorTest < ActiveSupport::TestCase transfer = creator.create assert transfer.persisted? - assert transfer.regular_transfer? - assert_equal "transfer", transfer.transfer_type - # Verify outflow transaction + # Verify outflow transaction is investment_contribution (not funds_movement) outflow = transfer.outflow_transaction - assert_equal "funds_movement", outflow.kind + assert_equal "investment_contribution", outflow.kind assert_equal "Transfer to #{crypto_account.name}", outflow.entry.name + assert_equal @investment_category, outflow.category # Verify inflow transaction with currency handling inflow = transfer.inflow_transaction @@ -70,6 +97,32 @@ class Transfer::CreatorTest < ActiveSupport::TestCase assert_equal crypto_account.currency, inflow.entry.currency end + test "creates funds_movement for investment to investment transfer (rollover)" do + # Rollover case: investment → investment should stay as funds_movement + other_investment = @family.accounts.create!(name: "IRA", balance: 5000, currency: "USD", accountable: Investment.new) + + creator = Transfer::Creator.new( + family: @family, + source_account_id: @destination_account.id, # investment account + destination_account_id: other_investment.id, + date: @date, + amount: @amount + ) + + transfer = creator.create + + assert transfer.persisted? + + # Verify outflow is funds_movement (NOT investment_contribution for rollovers) + outflow = transfer.outflow_transaction + assert_equal "funds_movement", outflow.kind + assert_nil outflow.category, "Should NOT auto-assign category for investment→investment transfers" + + # Verify inflow + inflow = transfer.inflow_transaction + assert_equal "funds_movement", inflow.kind + end + test "creates loan payment" do loan_account = accounts(:loan) diff --git a/test/test_helper.rb b/test/test_helper.rb index 16bab4495..867f19833 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -78,6 +78,16 @@ module ActiveSupport def user_password_test "maybetestpassword817983172" end + + # Ensures the Investment Contributions category exists for a family + # Used in transfer tests where this bootstrapped category is required + def ensure_investment_contributions_category(family) + family.categories.find_or_create_by!(name: Category.investment_contributions_name) do |c| + c.color = "#0d9488" + c.lucide_icon = "trending-up" + c.classification = "expense" + end + end end end