require "test_helper" class Transfer::CreatorTest < ActiveSupport::TestCase setup do @family = families(:dylan_family) @source_account = accounts(:depository) @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 investment contribution when transferring from depository to investment" do creator = Transfer::Creator.new( family: @family, source_account_id: @source_account.id, destination_account_id: @destination_account.id, date: @date, amount: @amount ) transfer = creator.create assert transfer.persisted? assert_equal "confirmed", transfer.status # Verify outflow transaction is marked as investment_contribution outflow = transfer.outflow_transaction assert_equal "investment_contribution", outflow.kind assert outflow.transfer?, "investment_contribution should be recognized as a transfer" 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 (always funds_movement) inflow = transfer.inflow_transaction assert_equal "funds_movement", inflow.kind assert inflow.transfer?, "funds_movement should be recognized as a transfer" assert_equal(@amount * -1, inflow.entry.amount) assert_equal @destination_account.currency, inflow.entry.currency assert_equal "Transfer from #{@source_account.name}", inflow.entry.name end 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( family: @family, source_account_id: @source_account.id, destination_account_id: crypto_account.id, date: @date, amount: @amount ) transfer = creator.create assert transfer.persisted? # Verify outflow transaction is investment_contribution (not funds_movement) outflow = transfer.outflow_transaction 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 assert_equal "funds_movement", inflow.kind assert_equal "Transfer from #{@source_account.name}", inflow.entry.name 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) creator = Transfer::Creator.new( family: @family, source_account_id: @source_account.id, destination_account_id: loan_account.id, date: @date, amount: @amount ) transfer = creator.create assert transfer.persisted? assert transfer.loan_payment? assert_equal "loan_payment", transfer.transfer_type # Verify outflow transaction is marked as loan payment outflow = transfer.outflow_transaction assert_equal "loan_payment", outflow.kind assert_equal "Payment to #{loan_account.name}", outflow.entry.name # Verify inflow transaction inflow = transfer.inflow_transaction assert_equal "funds_movement", inflow.kind assert_equal "Payment from #{@source_account.name}", inflow.entry.name end test "creates credit card payment" do credit_card_account = accounts(:credit_card) creator = Transfer::Creator.new( family: @family, source_account_id: @source_account.id, destination_account_id: credit_card_account.id, date: @date, amount: @amount ) transfer = creator.create assert transfer.persisted? assert transfer.liability_payment? assert_equal "liability_payment", transfer.transfer_type # Verify outflow transaction is marked as payment for liability outflow = transfer.outflow_transaction assert_equal "cc_payment", outflow.kind assert_equal "Payment to #{credit_card_account.name}", outflow.entry.name # Verify inflow transaction inflow = transfer.inflow_transaction assert_equal "funds_movement", inflow.kind assert_equal "Payment from #{@source_account.name}", inflow.entry.name end test "raises error when source account ID is invalid" do assert_raises(ActiveRecord::RecordNotFound) do Transfer::Creator.new( family: @family, source_account_id: 99999, destination_account_id: @destination_account.id, date: @date, amount: @amount ) end end test "raises error when destination account ID is invalid" do assert_raises(ActiveRecord::RecordNotFound) do Transfer::Creator.new( family: @family, source_account_id: @source_account.id, destination_account_id: 99999, date: @date, amount: @amount ) end end test "raises error when source account belongs to different family" do other_family = families(:empty) assert_raises(ActiveRecord::RecordNotFound) do Transfer::Creator.new( family: other_family, source_account_id: @source_account.id, destination_account_id: @destination_account.id, date: @date, amount: @amount ) end end test "creates transfer with custom exchange rate when provided" do # Create accounts with different currencies usd_account = @family.accounts.create!(name: "USD Account", balance: 1000, currency: "USD", accountable: Depository.new) eur_account = @family.accounts.create!(name: "EUR Account", balance: 1000, currency: "EUR", accountable: Depository.new) custom_rate = 0.92 # 1 USD = 0.92 EUR amount = 100 creator = Transfer::Creator.new( family: @family, source_account_id: usd_account.id, destination_account_id: eur_account.id, date: @date, amount: amount, exchange_rate: custom_rate ) transfer = creator.create assert transfer.persisted? # Verify outflow transaction is in source currency outflow = transfer.outflow_transaction assert_equal amount, outflow.entry.amount assert_equal "USD", outflow.entry.currency # Verify inflow transaction uses custom exchange rate inflow = transfer.inflow_transaction expected_eur_amount = (amount * custom_rate * -1).round(2) assert_in_delta expected_eur_amount, inflow.entry.amount, 0.01 assert_equal "EUR", inflow.entry.currency end test "falls back to fetched exchange rate when custom rate not provided" do # Create accounts with different currencies usd_account = @family.accounts.create!(name: "USD Account", balance: 1000, currency: "USD", accountable: Depository.new) gbp_account = @family.accounts.create!(name: "GBP Account", balance: 1000, currency: "GBP", accountable: Depository.new) # Mock the exchange rate lookup ExchangeRate.expects(:find_or_fetch_rate) .with(from: "USD", to: "GBP", date: @date) .returns(OpenStruct.new(rate: 0.79)) creator = Transfer::Creator.new( family: @family, source_account_id: usd_account.id, destination_account_id: gbp_account.id, date: @date, amount: 100 ) transfer = creator.create assert transfer.persisted? assert_equal "USD", transfer.outflow_transaction.entry.currency assert_equal "GBP", transfer.inflow_transaction.entry.currency end test "raises when no exchange rate available and none provided" do # Create accounts with different currencies usd_account = @family.accounts.create!(name: "USD Account", balance: 1000, currency: "USD", accountable: Depository.new) jpy_account = @family.accounts.create!(name: "JPY Account", balance: 100000, currency: "JPY", accountable: Depository.new) # Mock no exchange rate found ExchangeRate.expects(:find_or_fetch_rate) .with(from: "USD", to: "JPY", date: @date) .returns(nil) creator = Transfer::Creator.new( family: @family, source_account_id: usd_account.id, destination_account_id: jpy_account.id, date: @date, amount: 100 ) assert_raises(Money::ConversionError) do creator.create end end test "custom exchange rate with very small value is valid" do usd_account = @family.accounts.create!(name: "USD Account", balance: 1000, currency: "USD", accountable: Depository.new) eur_account = @family.accounts.create!(name: "EUR Account", balance: 1000, currency: "EUR", accountable: Depository.new) creator = Transfer::Creator.new( family: @family, source_account_id: usd_account.id, destination_account_id: eur_account.id, date: @date, amount: 100, exchange_rate: 0.000001 ) transfer = creator.create assert transfer.persisted? assert_in_delta(-0.0001, transfer.inflow_transaction.entry.amount, 0.0001) end end