From 800eb4c146e6cbc136ccf36cfacff2628ab435d6 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 12 Dec 2024 08:56:52 -0500 Subject: [PATCH] Plaid sync tests and multi-currency investment support (#1531) * Plaid sync tests and multi-currency investment support * Fix system test * Cleanup * Remove data migration --- .../account/holdings_controller.rb | 2 +- app/models/account.rb | 15 +- app/models/account/holding.rb | 5 +- app/models/account/holding_calculator.rb | 10 +- app/models/account/syncer.rb | 66 +++++---- app/models/investment.rb | 4 - app/models/plaid_account.rb | 64 +-------- app/models/plaid_investment_sync.rb | 95 +++++++++++++ app/models/provider/plaid.rb | 27 +--- app/models/provider/synth.rb | 15 +- app/views/account/holdings/_cash.html.erb | 2 +- app/views/account/holdings/index.html.erb | 4 +- app/views/accounts/show/_header.html.erb | 8 +- app/views/shared/_progress_circle.html.erb | 14 +- .../account/holdings_controller_test.rb | 2 +- test/fixtures/securities.yml | 3 + test/models/account/syncer_test.rb | 11 +- test/models/account_test.rb | 9 -- test/models/plaid_investment_sync_test.rb | 82 +++++++++++ test/support/plaid_test_helper.rb | 128 ++++++++++++++++++ test/system/trades_test.rb | 5 +- 21 files changed, 406 insertions(+), 165 deletions(-) create mode 100644 app/models/plaid_investment_sync.rb create mode 100644 test/models/plaid_investment_sync_test.rb create mode 100644 test/support/plaid_test_helper.rb diff --git a/app/controllers/account/holdings_controller.rb b/app/controllers/account/holdings_controller.rb index 174d45c6cbc..27ebcd9af6c 100644 --- a/app/controllers/account/holdings_controller.rb +++ b/app/controllers/account/holdings_controller.rb @@ -23,6 +23,6 @@ def destroy private def set_holding - @holding = Current.family.holdings.current.find(params[:id]) + @holding = Current.family.holdings.find(params[:id]) end end diff --git a/app/models/account.rb b/app/models/account.rb index feccd7d6e1f..05931b7b42b 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -118,11 +118,8 @@ def original_balance Money.new(balance_amount, currency) end - def owns_ticker?(ticker) - security_id = Security.find_by(ticker: ticker)&.id - entries.account_trades - .joins("JOIN account_trades ON account_entries.entryable_id = account_trades.id") - .where(account_trades: { security_id: security_id }).any? + def current_holdings + holdings.where(currency: currency, date: holdings.maximum(:date)).order(amount: :desc) end def favorable_direction @@ -151,12 +148,4 @@ def update_balance!(balance) entryable: Account::Valuation.new end end - - def holding_qty(security, date: Date.current) - entries.account_trades - .joins("JOIN account_trades ON account_entries.entryable_id = account_trades.id") - .where(account_trades: { security_id: security.id }) - .where("account_entries.date <= ?", date) - .sum("account_trades.qty") - end end diff --git a/app/models/account/holding.rb b/app/models/account/holding.rb index 432ec2deab8..d5f3e01457c 100644 --- a/app/models/account/holding.rb +++ b/app/models/account/holding.rb @@ -9,9 +9,6 @@ class Account::Holding < ApplicationRecord validates :qty, :currency, presence: true scope :chronological, -> { order(:date) } - scope :current, -> { where(date: Date.current).order(amount: :desc) } - scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) } - scope :known_value, -> { where.not(amount: nil) } scope :for, ->(security) { where(security_id: security).order(:date) } delegate :ticker, to: :security @@ -29,7 +26,7 @@ def weight # Basic approximation of cost-basis def avg_cost - avg_cost = account.holdings.for(security).where("date <= ?", date).average(:price) + avg_cost = account.holdings.for(security).where(currency: currency).where("date <= ?", date).average(:price) Money.new(avg_cost, currency) end diff --git a/app/models/account/holding_calculator.rb b/app/models/account/holding_calculator.rb index 815e3fdf679..b1f6fc2fc44 100644 --- a/app/models/account/holding_calculator.rb +++ b/app/models/account/holding_calculator.rb @@ -52,13 +52,15 @@ def generate_holding_records(portfolio, date) next if price.blank? + converted_price = Money.new(price.price, price.currency).exchange_to(account.currency, fallback_rate: 1).amount + account.holdings.build( security: security.dig(:security), date: date, qty: qty, - price: price.price, - currency: price.currency, - amount: qty * price.price + price: converted_price, + currency: account.currency, + amount: qty * converted_price ) end.compact end @@ -145,7 +147,7 @@ def load_empty_holding_quantities def load_current_holding_quantities holding_quantities = load_empty_holding_quantities - account.holdings.where(date: Date.current).map do |holding| + account.holdings.where(date: Date.current, currency: account.currency).map do |holding| holding_quantities[holding.security_id] = holding.qty end diff --git a/app/models/account/syncer.rb b/app/models/account/syncer.rb index 61a4ef99150..d42ff431a0a 100644 --- a/app/models/account/syncer.rb +++ b/app/models/account/syncer.rb @@ -9,7 +9,7 @@ def run balances = sync_balances(holdings) account.reload update_account_info(balances, holdings) unless account.plaid_account_id.present? - convert_foreign_records(balances) + convert_records_to_family_currency(balances, holdings) unless account.currency == account.family.currency end private @@ -37,12 +37,7 @@ def sync_holdings current_time = Time.now Account.transaction do - account.holdings.upsert_all( - calculated_holdings.map { |h| h.attributes - .slice("date", "currency", "qty", "price", "amount", "security_id") - .merge("updated_at" => current_time) }, - unique_by: %i[account_id security_id date currency] - ) if calculated_holdings.any? + load_holdings(calculated_holdings) # Purge outdated holdings account.holdings.delete_by("date < ? OR security_id NOT IN (?)", account_start_date, calculated_holdings.map(&:security_id)) @@ -65,24 +60,7 @@ def sync_balances(holdings) calculated_balances end - def convert_foreign_records(balances) - converted_balances = convert_balances(balances) - load_balances(converted_balances) - end - - def load_balances(balances) - current_time = Time.now - account.balances.upsert_all( - balances.map { |b| b.attributes - .slice("date", "balance", "cash_balance", "currency") - .merge("updated_at" => current_time) }, - unique_by: %i[account_id date currency] - ) if balances.any? - end - - def convert_balances(balances) - return [] if account.currency == account.family.currency - + def convert_records_to_family_currency(balances, holdings) from_currency = account.currency to_currency = account.family.currency @@ -92,7 +70,7 @@ def convert_balances(balances) start_date: balances.first.date ) - balances.map do |balance| + converted_balances = balances.map do |balance| exchange_rate = exchange_rates.find { |er| er.date == balance.date } account.balances.build( @@ -101,5 +79,41 @@ def convert_balances(balances) currency: to_currency ) if exchange_rate.present? end + + converted_holdings = holdings.map do |holding| + exchange_rate = exchange_rates.find { |er| er.date == holding.date } + + account.holdings.build( + security: holding.security, + date: holding.date, + amount: exchange_rate.rate * holding.amount, + currency: to_currency + ) if exchange_rate.present? + end + + Account.transaction do + load_balances(converted_balances) + load_holdings(converted_holdings) + end + end + + def load_balances(balances = []) + current_time = Time.now + account.balances.upsert_all( + balances.map { |b| b.attributes + .slice("date", "balance", "cash_balance", "currency") + .merge("updated_at" => current_time) }, + unique_by: %i[account_id date currency] + ) + end + + def load_holdings(holdings = []) + current_time = Time.now + account.holdings.upsert_all( + holdings.map { |h| h.attributes + .slice("date", "currency", "qty", "price", "amount", "security_id") + .merge("updated_at" => current_time) }, + unique_by: %i[account_id security_id date currency] + ) end end diff --git a/app/models/investment.rb b/app/models/investment.rb index 6f6c1e577a6..76e7a57c71c 100644 --- a/app/models/investment.rb +++ b/app/models/investment.rb @@ -23,8 +23,4 @@ def color def icon "line-chart" end - - def post_sync - broadcast_refresh_to account.family - end end diff --git a/app/models/plaid_account.rb b/app/models/plaid_account.rb index 8fe342a724b..5772f821bfe 100644 --- a/app/models/plaid_account.rb +++ b/app/models/plaid_account.rb @@ -45,50 +45,7 @@ def sync_account_data!(plaid_account_data) end def sync_investments!(transactions:, holdings:, securities:) - transactions.each do |transaction| - if transaction.type == "cash" - new_transaction = account.entries.find_or_create_by!(plaid_id: transaction.investment_transaction_id) do |t| - t.name = transaction.name - t.amount = transaction.amount - t.currency = transaction.iso_currency_code - t.date = transaction.date - t.marked_as_transfer = transaction.subtype.in?(%w[deposit withdrawal]) - t.entryable = Account::Transaction.new - end - else - security = get_security(transaction.security, securities) - next if security.nil? - new_transaction = account.entries.find_or_create_by!(plaid_id: transaction.investment_transaction_id) do |t| - t.name = transaction.name - t.amount = transaction.quantity * transaction.price - t.currency = transaction.iso_currency_code - t.date = transaction.date - t.entryable = Account::Trade.new( - security: security, - qty: transaction.quantity, - price: transaction.price, - currency: transaction.iso_currency_code - ) - end - end - end - - # Update only the current day holdings. The account sync will populate historical values based on trades. - holdings.each do |holding| - internal_security = get_security(holding.security, securities) - next if internal_security.nil? - - existing_holding = account.holdings.find_or_initialize_by( - security: internal_security, - date: Date.current, - currency: holding.iso_currency_code - ) - - existing_holding.qty = holding.quantity - existing_holding.price = holding.institution_price - existing_holding.amount = holding.quantity * holding.institution_price - existing_holding.save! - end + PlaidInvestmentSync.new(self).sync!(transactions:, holdings:, securities:) end def sync_credit_data!(plaid_credit_data) @@ -159,25 +116,6 @@ def family plaid_item.family end - def get_security(plaid_security, securities) - return nil if plaid_security.nil? - - security = if plaid_security.ticker_symbol.present? - plaid_security - else - securities.find { |s| s.security_id == plaid_security.proxy_security_id } - end - - return nil if security.nil? || security.ticker_symbol.blank? - return nil if security.ticker_symbol == "CUR:USD" # Internally, we do not consider cash a "holding" and track it separately - - Security.find_or_create_by!( - ticker: security.ticker_symbol, - exchange_mic: security.market_identifier_code || "XNAS", - country_code: "US" - ) - end - def transfer?(plaid_txn) transfer_categories = [ "TRANSFER_IN", "TRANSFER_OUT", "LOAN_PAYMENTS" ] diff --git a/app/models/plaid_investment_sync.rb b/app/models/plaid_investment_sync.rb new file mode 100644 index 00000000000..fd207116f79 --- /dev/null +++ b/app/models/plaid_investment_sync.rb @@ -0,0 +1,95 @@ +class PlaidInvestmentSync + attr_reader :plaid_account + + def initialize(plaid_account) + @plaid_account = plaid_account + end + + def sync!(transactions: [], holdings: [], securities: []) + @transactions = transactions + @holdings = holdings + @securities = securities + + PlaidAccount.transaction do + sync_transactions! + sync_holdings! + end + end + + private + attr_reader :transactions, :holdings, :securities + + def sync_transactions! + transactions.each do |transaction| + security, plaid_security = get_security(transaction.security_id, securities) + + next if security.nil? && plaid_security.nil? + + if transaction.type == "cash" || plaid_security.ticker_symbol == "CUR:USD" + new_transaction = plaid_account.account.entries.find_or_create_by!(plaid_id: transaction.investment_transaction_id) do |t| + t.name = transaction.name + t.amount = transaction.amount + t.currency = transaction.iso_currency_code + t.date = transaction.date + t.marked_as_transfer = transaction.subtype.in?(%w[deposit withdrawal]) + t.entryable = Account::Transaction.new + end + else + new_transaction = plaid_account.account.entries.find_or_create_by!(plaid_id: transaction.investment_transaction_id) do |t| + t.name = transaction.name + t.amount = transaction.quantity * transaction.price + t.currency = transaction.iso_currency_code + t.date = transaction.date + t.entryable = Account::Trade.new( + security: security, + qty: transaction.quantity, + price: transaction.price, + currency: transaction.iso_currency_code + ) + end + end + end + end + + def sync_holdings! + # Update only the current day holdings. The account sync will populate historical values based on trades. + holdings.each do |holding| + internal_security, _plaid_security = get_security(holding.security_id, securities) + + next if internal_security.nil? + + existing_holding = plaid_account.account.holdings.find_or_initialize_by( + security: internal_security, + date: Date.current, + currency: holding.iso_currency_code + ) + + existing_holding.qty = holding.quantity + existing_holding.price = holding.institution_price + existing_holding.amount = holding.quantity * holding.institution_price + existing_holding.save! + end + end + + def get_security(plaid_security_id, securities) + plaid_security = securities.find { |s| s.security_id == plaid_security_id } + + return [ nil, nil ] if plaid_security.nil? + + plaid_security = if plaid_security.ticker_symbol.present? + plaid_security + else + securities.find { |s| s.security_id == plaid_security.proxy_security_id } + end + + return [ nil, nil ] if plaid_security.nil? || plaid_security.ticker_symbol.blank? + + security = Security.find_or_create_by!( + ticker: plaid_security.ticker_symbol, + exchange_mic: plaid_security.market_identifier_code || "XNAS", + country_code: "US" + ) unless plaid_security.ticker_symbol == "CUR:USD" # internally, we do not consider cash a security and track it separately + + [ security, plaid_security ] + end +end diff --git a/app/models/provider/plaid.rb b/app/models/provider/plaid.rb index 7fd182912f9..e41a0a46dab 100644 --- a/app/models/provider/plaid.rb +++ b/app/models/provider/plaid.rb @@ -134,10 +134,12 @@ def get_item_transactions(item) def get_item_investments(item, start_date: nil, end_date: Date.current) start_date = start_date || MAX_HISTORY_DAYS.days.ago.to_date - holdings = get_item_holdings(item) - transactions, securities = get_item_investment_transactions(item, start_date:, end_date:) + holdings, holding_securities = get_item_holdings(item) + transactions, transaction_securities = get_item_investment_transactions(item, start_date:, end_date:) - InvestmentsResponse.new(holdings:, transactions:, securities:) + merged_securities = ((holding_securities || []) + (transaction_securities || [])).uniq { |s| s.security_id } + + InvestmentsResponse.new(holdings:, transactions:, securities: merged_securities) end def get_item_liabilities(item) @@ -154,15 +156,7 @@ def get_item_holdings(item) request = Plaid::InvestmentsHoldingsGetRequest.new({ access_token: item.access_token }) response = client.investments_holdings_get(request) - securities_by_id = response.securities.index_by(&:security_id) - accounts_by_id = response.accounts.index_by(&:account_id) - - response.holdings.each do |holding| - holding.define_singleton_method(:security) { securities_by_id[holding.security_id] } - holding.define_singleton_method(:account) { accounts_by_id[holding.account_id] } - end - - response.holdings + [ response.holdings, response.securities ] end def get_item_investment_transactions(item, start_date:, end_date:) @@ -179,15 +173,8 @@ def get_item_investment_transactions(item, start_date:, end_date:) ) response = client.investments_transactions_get(request) - securities_by_id = response.securities.index_by(&:security_id) - accounts_by_id = response.accounts.index_by(&:account_id) - - response.investment_transactions.each do |t| - t.define_singleton_method(:security) { securities_by_id[t.security_id] } - t.define_singleton_method(:account) { accounts_by_id[t.account_id] } - transactions << t - end + transactions += response.investment_transactions securities += response.securities break if transactions.length >= response.total_investment_transactions diff --git a/app/models/provider/synth.rb b/app/models/provider/synth.rb index 5044f00abf8..c212e992222 100644 --- a/app/models/provider/synth.rb +++ b/app/models/provider/synth.rb @@ -43,18 +43,23 @@ def usage ) end - def fetch_security_prices(ticker:, mic_code:, start_date:, end_date:) - prices = paginate( - "#{base_url}/tickers/#{ticker}/open-close", - mic_code: mic_code, + def fetch_security_prices(ticker:, start_date:, end_date:, mic_code: nil) + params = { start_date: start_date, end_date: end_date + } + + params[:mic_code] = mic_code if mic_code.present? + + prices = paginate( + "#{base_url}/tickers/#{ticker}/open-close", + params ) do |body| body.dig("prices").map do |price| { date: price.dig("date"), price: price.dig("close")&.to_f || price.dig("open")&.to_f, - currency: "USD" + currency: price.dig("currency") || "USD" } end end diff --git a/app/views/account/holdings/_cash.html.erb b/app/views/account/holdings/_cash.html.erb index cc135a0696c..51c62f2803b 100644 --- a/app/views/account/holdings/_cash.html.erb +++ b/app/views/account/holdings/_cash.html.erb @@ -23,7 +23,7 @@
- <%= tag.p format_money account.cash_balance %> + <%= tag.p format_money account.cash_balance_money %>
diff --git a/app/views/account/holdings/index.html.erb b/app/views/account/holdings/index.html.erb index 8e96fa9531e..195a26048bb 100644 --- a/app/views/account/holdings/index.html.erb +++ b/app/views/account/holdings/index.html.erb @@ -21,10 +21,10 @@
- <% if @account.holdings.current.any? %> + <% if @account.current_holdings.any? %> <%= render "account/holdings/cash", account: @account %> <%= render "account/holdings/ruler" %> - <%= render partial: "account/holdings/holding", collection: @account.holdings.current, spacer_template: "ruler" %> + <%= render partial: "account/holdings/holding", collection: @account.current_holdings, spacer_template: "ruler" %> <% else %>

<%= t(".no_holdings") %>

<% end %> diff --git a/app/views/accounts/show/_header.html.erb b/app/views/accounts/show/_header.html.erb index 5abd0873beb..a6e925e9e29 100644 --- a/app/views/accounts/show/_header.html.erb +++ b/app/views/accounts/show/_header.html.erb @@ -20,7 +20,13 @@ <% end %>
- <% unless account.plaid_account_id.present? %> + <% if account.plaid_account_id.present? %> + <% if Rails.env.development? %> + <%= button_to sync_plaid_item_path(account.plaid_account.plaid_item), disabled: account.syncing?, data: { turbo: false }, class: "flex items-center gap-2", title: "Sync Account" do %> + <%= lucide_icon "refresh-cw", class: "w-4 h-4 text-gray-500 hover:text-gray-400" %> + <% end %> + <% end %> + <% else %> <%= button_to sync_account_path(account), disabled: account.syncing?, data: { turbo: false }, class: "flex items-center gap-2", title: "Sync Account" do %> <%= lucide_icon "refresh-cw", class: "w-4 h-4 text-gray-500 hover:text-gray-400" %> <% end %> diff --git a/app/views/shared/_progress_circle.html.erb b/app/views/shared/_progress_circle.html.erb index a3529193e88..f35bda25552 100644 --- a/app/views/shared/_progress_circle.html.erb +++ b/app/views/shared/_progress_circle.html.erb @@ -1,7 +1,7 @@ <%# locals: (progress:, radius: 7, stroke: 2, text_class: "text-green-500") %> -<% - circumference = Math::PI * 2 * radius +<% + circumference = Math::PI * 2 * radius progress_percent = progress.clamp(0, 100) stroke_dashoffset = ((100 - progress_percent) * circumference) / 100 center = radius + stroke / 2 @@ -9,16 +9,15 @@ - + stroke-width="<%= stroke %>"> - + transform="rotate(-90, <%= center %>, <%= center %>)"> diff --git a/test/controllers/account/holdings_controller_test.rb b/test/controllers/account/holdings_controller_test.rb index 7bca9671a32..b355c061a8f 100644 --- a/test/controllers/account/holdings_controller_test.rb +++ b/test/controllers/account/holdings_controller_test.rb @@ -4,7 +4,7 @@ class Account::HoldingsControllerTest < ActionDispatch::IntegrationTest setup do sign_in users(:family_admin) @account = accounts(:investment) - @holding = @account.holdings.current.first + @holding = @account.holdings.first end test "gets holdings" do diff --git a/test/fixtures/securities.yml b/test/fixtures/securities.yml index 16d48a55a84..e5e386873ba 100644 --- a/test/fixtures/securities.yml +++ b/test/fixtures/securities.yml @@ -2,8 +2,11 @@ aapl: ticker: AAPL name: Apple exchange_mic: XNAS + country_code: US msft: ticker: MSFT name: Microsoft exchange_mic: XNAS + country_code: US + diff --git a/test/models/account/syncer_test.rb b/test/models/account/syncer_test.rb index b03ee3d9ee5..bfbf4a874e0 100644 --- a/test/models/account/syncer_test.rb +++ b/test/models/account/syncer_test.rb @@ -13,7 +13,7 @@ class Account::SyncerTest < ActiveSupport::TestCase ) end - test "converts foreign account balances to family currency" do + test "converts foreign account balances and holdings to family currency" do @account.family.update! currency: "USD" @account.update! currency: "EUR" @@ -27,10 +27,19 @@ class Account::SyncerTest < ActiveSupport::TestCase ] ) + Account::HoldingCalculator.any_instance.expects(:calculate).returns( + [ + Account::Holding.new(security: securities(:aapl), date: 1.day.ago.to_date, amount: 500, currency: "EUR"), + Account::Holding.new(security: securities(:aapl), date: Date.current, amount: 500, currency: "EUR") + ] + ) + Account::Syncer.new(@account).run assert_equal [ 1000, 1000 ], @account.balances.where(currency: "EUR").chronological.map(&:balance) assert_equal [ 1200, 2000 ], @account.balances.where(currency: "USD").chronological.map(&:balance) + assert_equal [ 500, 500 ], @account.holdings.where(currency: "EUR").chronological.map(&:amount) + assert_equal [ 600, 1000 ], @account.holdings.where(currency: "USD").chronological.map(&:amount) end test "purges stale balances and holdings" do diff --git a/test/models/account_test.rb b/test/models/account_test.rb index 15dc923fe04..f122c17e313 100644 --- a/test/models/account_test.rb +++ b/test/models/account_test.rb @@ -59,13 +59,4 @@ class AccountTest < ActiveSupport::TestCase assert_equal 0, @account.series(currency: "NZD").values.count end end - - test "calculates shares owned of holding for date" do - account = accounts(:investment) - security = securities(:aapl) - - assert_equal 10, account.holding_qty(security, date: Date.current) - assert_equal 10, account.holding_qty(security, date: 1.day.ago.to_date) - assert_equal 0, account.holding_qty(security, date: 2.days.ago.to_date) - end end diff --git a/test/models/plaid_investment_sync_test.rb b/test/models/plaid_investment_sync_test.rb new file mode 100644 index 00000000000..052a941ad62 --- /dev/null +++ b/test/models/plaid_investment_sync_test.rb @@ -0,0 +1,82 @@ +require "test_helper" + +class PlaidInvestmentSyncTest < ActiveSupport::TestCase + include PlaidTestHelper + + setup do + @plaid_account = plaid_accounts(:one) + end + + test "syncs basic investments and handles cash holding" do + assert_equal 0, @plaid_account.account.entries.count + assert_equal 0, @plaid_account.account.holdings.count + + plaid_aapl_id = "aapl_id" + + transactions = [ + create_plaid_investment_transaction({ + investment_transaction_id: "inv_txn_1", + security_id: plaid_aapl_id, + quantity: 10, + price: 200, + date: 5.days.ago.to_date, + type: "buy" + }) + ] + + holdings = [ + create_plaid_cash_holding, + create_plaid_holding({ + security_id: plaid_aapl_id, + quantity: 10, + institution_price: 200, + cost_basis: 2000 + }) + ] + + securities = [ + create_plaid_security({ + security_id: plaid_aapl_id, + close_price: 200, + ticker_symbol: "AAPL" + }) + ] + + # Cash holding should be ignored, resulting in 1, NOT 2 total holdings after sync + assert_difference -> { Account::Trade.count } => 1, + -> { Account::Transaction.count } => 0, + -> { Account::Holding.count } => 1, + -> { Security.count } => 0 do + PlaidInvestmentSync.new(@plaid_account).sync!( + transactions: transactions, + holdings: holdings, + securities: securities + ) + end + end + + # Some cash transactions from Plaid are labeled as type: "cash" while others are linked to a "cash" security + # In both cases, we should treat them as cash-only transactions (not trades) + test "handles cash investment transactions" do + transactions = [ + create_plaid_investment_transaction({ + price: 1, + quantity: 5, + amount: 5, + type: "fee", + subtype: "miscellaneous fee", + security_id: PLAID_TEST_CASH_SECURITY_ID + }) + ] + + assert_difference -> { Account::Trade.count } => 0, + -> { Account::Transaction.count } => 1, + -> { Security.count } => 0 do + PlaidInvestmentSync.new(@plaid_account).sync!( + transactions: transactions, + holdings: [ create_plaid_cash_holding ], + securities: [ create_plaid_cash_security ] + ) + end + end +end diff --git a/test/support/plaid_test_helper.rb b/test/support/plaid_test_helper.rb new file mode 100644 index 00000000000..b732bb9786f --- /dev/null +++ b/test/support/plaid_test_helper.rb @@ -0,0 +1,128 @@ +require "ostruct" + +module PlaidTestHelper + PLAID_TEST_ACCOUNT_ID = "plaid_test_account_id" + PLAID_TEST_CASH_SECURITY_ID = "plaid_test_cash_security_id" + + # Special case + def create_plaid_cash_security(attributes = {}) + default_attributes = { + close_price: nil, + close_price_as_of: nil, + cusip: nil, + fixed_income: nil, + industry: nil, + institution_id: nil, + institution_security_id: nil, + is_cash_equivalent: false, # Plaid sometimes returns false here (bad data), so we should not rely on it + isin: nil, + iso_currency_code: "USD", + market_identifier_code: nil, + name: "US Dollar", + option_contract: nil, + proxy_security_id: nil, + sector: nil, + security_id: PLAID_TEST_CASH_SECURITY_ID, + sedol: nil, + ticker_symbol: "CUR:USD", + type: "cash", + unofficial_currency_code: nil, + update_datetime: nil + } + + OpenStruct.new( + default_attributes.merge(attributes) + ) + end + + def create_plaid_security(attributes = {}) + default_attributes = { + close_price: 606.71, + close_price_as_of: Date.current, + cusip: nil, + fixed_income: nil, + industry: "Mutual Funds", + institution_id: nil, + institution_security_id: nil, + is_cash_equivalent: false, + isin: nil, + iso_currency_code: "USD", + market_identifier_code: "XNAS", + name: "iShares S&P 500 Index", + option_contract: nil, + proxy_security_id: nil, + sector: "Financial", + security_id: "plaid_test_security_id", + sedol: "2593025", + ticker_symbol: "IVV", + type: "etf", + unofficial_currency_code: nil, + update_datetime: nil + } + + OpenStruct.new( + default_attributes.merge(attributes) + ) + end + + def create_plaid_cash_holding(attributes = {}) + default_attributes = { + account_id: PLAID_TEST_ACCOUNT_ID, + cost_basis: 1000, + institution_price: 1, + institution_price_as_of: Date.current, + iso_currency_code: "USD", + quantity: 1000, + security_id: PLAID_TEST_CASH_SECURITY_ID, + unofficial_currency_code: nil, + vested_quantity: nil, + vested_value: nil + } + + OpenStruct.new( + default_attributes.merge(attributes) + ) + end + + def create_plaid_holding(attributes = {}) + default_attributes = { + account_id: PLAID_TEST_ACCOUNT_ID, + cost_basis: 2000, + institution_price: 200, + institution_price_as_of: Date.current, + iso_currency_code: "USD", + quantity: 10, + security_id: "plaid_test_security_id", + unofficial_currency_code: nil, + vested_quantity: nil, + vested_value: nil + } + + OpenStruct.new( + default_attributes.merge(attributes) + ) + end + + def create_plaid_investment_transaction(attributes = {}) + default_attributes = { + account_id: PLAID_TEST_ACCOUNT_ID, + amount: 500, + cancel_transaction_id: nil, + date: 5.days.ago.to_date, + fees: 0, + investment_transaction_id: "plaid_test_investment_transaction_id", + iso_currency_code: "USD", + name: "Buy 100 shares of IVV", + price: 606.71, + quantity: 100, + security_id: "plaid_test_security_id", + type: "buy", + subtype: "buy", + unofficial_currency_code: nil + } + + OpenStruct.new( + default_attributes.merge(attributes) + ) + end +end diff --git a/test/system/trades_test.rb b/test/system/trades_test.rb index 79945a3e8bc..9e26b7083a4 100644 --- a/test/system/trades_test.rb +++ b/test/system/trades_test.rb @@ -16,7 +16,8 @@ class TradesTest < ApplicationSystemTestCase name: "Apple Inc.", logo_url: "https://logo.synthfinance.com/ticker/AAPL", exchange_acronym: "NASDAQ", - exchange_mic: "XNAS" + exchange_mic: "XNAS", + country_code: "US" ) ]) end @@ -43,7 +44,7 @@ class TradesTest < ApplicationSystemTestCase end test "can create sell transaction" do - aapl = @account.holdings.current.find { |h| h.security.ticker == "AAPL" } + aapl = @account.holdings.find { |h| h.security.ticker == "AAPL" } open_new_trade_modal