diff --git a/app/models/family/auto_categorizer.rb b/app/models/family/auto_categorizer.rb index 25fde493..1ac8b874 100644 --- a/app/models/family/auto_categorizer.rb +++ b/app/models/family/auto_categorizer.rb @@ -70,8 +70,7 @@ class Family::AutoCategorizer amount: transaction.entry.amount.abs, classification: transaction.entry.classification, description: transaction.entry.name, - merchant: transaction.merchant&.name, - hint: transaction.plaid_category_detailed + merchant: transaction.merchant&.name } end end diff --git a/app/models/plaid_account/investments/holdings_processor.rb b/app/models/plaid_account/investments/holdings_processor.rb index b5671151..cfaaa5b3 100644 --- a/app/models/plaid_account/investments/holdings_processor.rb +++ b/app/models/plaid_account/investments/holdings_processor.rb @@ -27,7 +27,7 @@ class PlaidAccount::Investments::HoldingsProcessor end private - attr_reader :plaid_account + attr_reader :plaid_account, :security_resolver def account plaid_account.account diff --git a/app/models/plaid_account/investments/transactions_processor.rb b/app/models/plaid_account/investments/transactions_processor.rb index faa285fc..9dcebdb0 100644 --- a/app/models/plaid_account/investments/transactions_processor.rb +++ b/app/models/plaid_account/investments/transactions_processor.rb @@ -1,4 +1,6 @@ class PlaidAccount::Investments::TransactionsProcessor + SecurityNotFoundError = Class.new(StandardError) + def initialize(plaid_account, security_resolver:) @plaid_account = plaid_account @security_resolver = security_resolver @@ -6,12 +8,10 @@ class PlaidAccount::Investments::TransactionsProcessor def process transactions.each do |transaction| - resolved_security_result = security_resolver.resolve(plaid_security_id: transaction["security_id"]) - - if resolved_security_result.security.present? - find_or_create_trade_entry(transaction) - else + if cash_transaction?(transaction) find_or_create_cash_entry(transaction) + else + find_or_create_trade_entry(transaction) end end end @@ -23,17 +23,25 @@ class PlaidAccount::Investments::TransactionsProcessor plaid_account.account end + def cash_transaction?(transaction) + transaction["type"] == "cash" || transaction["type"] == "fee" + end + def find_or_create_trade_entry(transaction) + resolved_security_result = security_resolver.resolve(plaid_security_id: transaction["security_id"]) + + unless resolved_security_result.security.present? + Sentry.capture_exception(SecurityNotFoundError.new("Could not find security for plaid trade")) do |scope| + scope.set_tags(plaid_account_id: plaid_account.id) + end + + return # We can't process a non-cash transaction without a security + end + entry = account.entries.find_or_initialize_by(plaid_id: transaction["investment_transaction_id"]) do |e| e.entryable = Trade.new end - entry.enrich_attribute( - :name, - transaction["name"], - source: "plaid" - ) - entry.assign_attributes( amount: transaction["quantity"] * transaction["price"], currency: transaction["iso_currency_code"], @@ -41,12 +49,18 @@ class PlaidAccount::Investments::TransactionsProcessor ) entry.trade.assign_attributes( - security: security, + security: resolved_security_result.security, qty: transaction["quantity"], price: transaction["price"], currency: transaction["iso_currency_code"] ) + entry.enrich_attribute( + :name, + transaction["name"], + source: "plaid" + ) + entry.save! end @@ -55,18 +69,18 @@ class PlaidAccount::Investments::TransactionsProcessor e.entryable = Transaction.new end - entry.enrich_attribute( - :name, - transaction["name"], - source: "plaid" - ) - entry.assign_attributes( amount: transaction["amount"], currency: transaction["iso_currency_code"], date: transaction["date"] ) + entry.enrich_attribute( + :name, + transaction["name"], + source: "plaid" + ) + entry.save! end diff --git a/app/models/plaid_account/transactions/processor.rb b/app/models/plaid_account/transactions/processor.rb index 155b12fe..8aa07162 100644 --- a/app/models/plaid_account/transactions/processor.rb +++ b/app/models/plaid_account/transactions/processor.rb @@ -6,7 +6,7 @@ class PlaidAccount::Transactions::Processor def process # 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. - modified_transactions.find_each do |transaction| + modified_transactions.each do |transaction| PlaidEntry::Processor.new( transaction, plaid_account: plaid_account, @@ -15,7 +15,7 @@ class PlaidAccount::Transactions::Processor end PlaidAccount.transaction do - removed_transactions.find_each do |transaction| + removed_transactions.each do |transaction| remove_plaid_transaction(transaction) end end diff --git a/test/models/plaid_account/investments/holdings_processor_test.rb b/test/models/plaid_account/investments/holdings_processor_test.rb index 5d0a06ab..ac5b5895 100644 --- a/test/models/plaid_account/investments/holdings_processor_test.rb +++ b/test/models/plaid_account/investments/holdings_processor_test.rb @@ -2,14 +2,48 @@ require "test_helper" class PlaidAccount::Investments::HoldingsProcessorTest < ActiveSupport::TestCase setup do - # TODO: set up holdings data and security resolver stub + @plaid_account = plaid_accounts(:one) + @security_resolver = PlaidAccount::Investments::SecurityResolver.new(@plaid_account) end test "creates holding records from Plaid holdings snapshot" do - # TODO - end + test_investments_payload = { + securities: [], # mocked + holdings: [ + { + "security_id" => "123", + "quantity" => 100, + "institution_price" => 100, + "iso_currency_code" => "USD" + } + ], + transactions: [] # not relevant for test + } - test "upserts security records via SecurityResolver" do - # TODO + @plaid_account.update!(raw_investments_payload: test_investments_payload) + + @security_resolver.expects(:resolve) + .with(plaid_security_id: "123") + .returns( + OpenStruct.new( + security: securities(:aapl), + cash_equivalent?: false, + brokerage_cash?: false + ) + ) + + processor = PlaidAccount::Investments::HoldingsProcessor.new(@plaid_account, security_resolver: @security_resolver) + + assert_difference "Holding.count" do + processor.process + end + + holding = Holding.order(created_at: :desc).first + + assert_equal 100, holding.qty + assert_equal 100, holding.price + assert_equal "USD", holding.currency + assert_equal securities(:aapl), holding.security + assert_equal Date.current, holding.date end end diff --git a/test/models/plaid_account/investments/security_resolver_test.rb b/test/models/plaid_account/investments/security_resolver_test.rb index d154d536..a32430c6 100644 --- a/test/models/plaid_account/investments/security_resolver_test.rb +++ b/test/models/plaid_account/investments/security_resolver_test.rb @@ -2,14 +2,114 @@ require "test_helper" class PlaidAccount::Investments::SecurityResolverTest < ActiveSupport::TestCase setup do - # TODO: stub security provider lookup + @upstream_resolver = mock("Security::Resolver") + @plaid_account = plaid_accounts(:one) + @resolver = PlaidAccount::Investments::SecurityResolver.new(@plaid_account) end - test "finds existing security by identifiers" do - # TODO + test "handles missing plaid security" do + missing_id = "missing_security_id" + + # Ensure there are *no* securities that reference the missing ID + @plaid_account.update!(raw_investments_payload: { + securities: [ + { + "security_id" => "some_other_id", + "ticker_symbol" => "FOO", + "type" => "equity", + "market_identifier_code" => "XNAS" + } + ] + }) + + Security::Resolver.expects(:new).never + Sentry.stubs(:capture_exception) + + response = @resolver.resolve(plaid_security_id: missing_id) + + assert_nil response.security + refute response.cash_equivalent? + refute response.brokerage_cash? end - test "creates new security when none found" do - # TODO + test "identifies brokerage cash plaid securities" do + brokerage_cash_id = "brokerage_cash_security_id" + + @plaid_account.update!(raw_investments_payload: { + securities: [ + { + "security_id" => brokerage_cash_id, + "ticker_symbol" => "CUR:USD", # Plaid brokerage cash ticker + "type" => "cash", + "is_cash_equivalent" => true + } + ] + }) + + Security::Resolver.expects(:new).never + + response = @resolver.resolve(plaid_security_id: brokerage_cash_id) + + assert_nil response.security + assert response.cash_equivalent? + assert response.brokerage_cash? + end + + test "identifies cash equivalent plaid securities" do + mmf_security_id = "money_market_security_id" + + @plaid_account.update!(raw_investments_payload: { + securities: [ + { + "security_id" => mmf_security_id, + "ticker_symbol" => "VMFXX", # Vanguard Federal Money Market Fund + "type" => "mutual fund", + "is_cash_equivalent" => true, + "market_identifier_code" => "XNAS" + } + ] + }) + + resolved_security = Security.create!(ticker: "VMFXX", exchange_operating_mic: "XNAS") + + Security::Resolver.expects(:new) + .with("VMFXX", exchange_operating_mic: "XNAS") + .returns(@upstream_resolver) + @upstream_resolver.expects(:resolve).returns(resolved_security) + + response = @resolver.resolve(plaid_security_id: mmf_security_id) + + assert_equal resolved_security, response.security + assert response.cash_equivalent? + refute response.brokerage_cash? + end + + test "resolves normal plaid securities" do + security_id = "regular_security_id" + + @plaid_account.update!(raw_investments_payload: { + securities: [ + { + "security_id" => security_id, + "ticker_symbol" => "IVV", + "type" => "etf", + "is_cash_equivalent" => false, + "market_identifier_code" => "XNAS" + } + ] + }) + + resolved_security = Security.create!(ticker: "IVV", exchange_operating_mic: "XNAS") + + Security::Resolver.expects(:new) + .with("IVV", exchange_operating_mic: "XNAS") + .returns(@upstream_resolver) + @upstream_resolver.expects(:resolve).returns(resolved_security) + + response = @resolver.resolve(plaid_security_id: security_id) + + assert_equal resolved_security, response.security + refute response.cash_equivalent? # Normal securities are not cash equivalent + refute response.brokerage_cash? end end diff --git a/test/models/plaid_account/investments/transactions_processor_test.rb b/test/models/plaid_account/investments/transactions_processor_test.rb index a377865b..8a0c9efd 100644 --- a/test/models/plaid_account/investments/transactions_processor_test.rb +++ b/test/models/plaid_account/investments/transactions_processor_test.rb @@ -2,14 +2,110 @@ require "test_helper" class PlaidAccount::Investments::TransactionsProcessorTest < ActiveSupport::TestCase setup do - # TODO: set up investment plaid account and security resolver stub + @plaid_account = plaid_accounts(:one) + @security_resolver = PlaidAccount::Investments::SecurityResolver.new(@plaid_account) end - test "creates trade entries from Plaid investment transactions" do - # TODO + + test "creates regular trade entries" do + test_investments_payload = { + transactions: [ + { + "transaction_id" => "123", + "security_id" => "123", + "type" => "buy", + "quantity" => 1, # Positive, so "buy 1 share" + "price" => 100, + "iso_currency_code" => "USD", + "date" => Date.current, + "name" => "Buy 1 share of AAPL" + } + ] + } + + @plaid_account.update!(raw_investments_payload: test_investments_payload) + + @security_resolver.stubs(:resolve).returns(OpenStruct.new( + security: securities(:aapl) + )) + + processor = PlaidAccount::Investments::TransactionsProcessor.new(@plaid_account, security_resolver: @security_resolver) + + assert_difference [ "Entry.count", "Trade.count" ], 1 do + processor.process + end + + entry = Entry.order(created_at: :desc).first + + assert_equal 100, entry.amount + assert_equal "USD", entry.currency + assert_equal Date.current, entry.date + assert_equal "Buy 1 share of AAPL", entry.name end - test "handles security resolution for unknown securities" do - # TODO + test "creates cash transactions" do + test_investments_payload = { + transactions: [ + { + "transaction_id" => "123", + "type" => "cash", + "subtype" => "withdrawal", + "amount" => 100, # Positive, so moving money OUT of the account + "iso_currency_code" => "USD", + "date" => Date.current, + "name" => "Withdrawal" + } + ] + } + + @plaid_account.update!(raw_investments_payload: test_investments_payload) + + @security_resolver.expects(:resolve).never # Cash transactions don't have a security + + 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 100, entry.amount + assert_equal "USD", entry.currency + assert_equal Date.current, entry.date + assert_equal "Withdrawal", entry.name + end + + test "creates fee transactions" do + test_investments_payload = { + transactions: [ + { + "transaction_id" => "123", + "type" => "fee", + "subtype" => "miscellaneous fee", + "amount" => 10.25, + "iso_currency_code" => "USD", + "date" => Date.current, + "name" => "Miscellaneous fee" + } + ] + } + + @plaid_account.update!(raw_investments_payload: test_investments_payload) + + @security_resolver.expects(:resolve).never # Cash transactions don't have a security + + 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 10.25, entry.amount + assert_equal "USD", entry.currency + assert_equal Date.current, entry.date + assert_equal "Miscellaneous fee", entry.name end end diff --git a/test/models/plaid_account/transactions/processor_test.rb b/test/models/plaid_account/transactions/processor_test.rb index 3c5bc10d..85272b11 100644 --- a/test/models/plaid_account/transactions/processor_test.rb +++ b/test/models/plaid_account/transactions/processor_test.rb @@ -15,16 +15,19 @@ class PlaidAccount::Transactions::ProcessorTest < ActiveSupport::TestCase removed: [] }) - mock_processor = mock("PlaidEntry::TransactionProcessor") - PlaidEntry::TransactionProcessor.expects(:new) - .with(added_transactions.first, plaid_account: @plaid_account) - .returns(mock_processor) - .once + mock_processor = mock("PlaidEntry::Processor") + category_matcher_mock = mock("PlaidAccount::Transactions::CategoryMatcher") - PlaidEntry::TransactionProcessor.expects(:new) - .with(modified_transactions.first, plaid_account: @plaid_account) - .returns(mock_processor) - .once + PlaidAccount::Transactions::CategoryMatcher.stubs(:new).returns(category_matcher_mock) + PlaidEntry::Processor.expects(:new) + .with(added_transactions.first, plaid_account: @plaid_account, category_matcher: category_matcher_mock) + .returns(mock_processor) + .once + + PlaidEntry::Processor.expects(:new) + .with(modified_transactions.first, plaid_account: @plaid_account, category_matcher: category_matcher_mock) + .returns(mock_processor) + .once mock_processor.expects(:process).twice