From ef2ba0e54e9943d0c98eb68ff49602bdc02783dd Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 23 May 2025 11:29:23 -0400 Subject: [PATCH] Plaid balance calculator test cases --- .../investments/balance_calculator.rb | 25 +++++- .../investments/security_resolver.rb | 10 +-- .../investments/balance_calculator_test.rb | 83 +++++++++++++++++++ 3 files changed, 107 insertions(+), 11 deletions(-) create mode 100644 test/models/plaid_account/investments/balance_calculator_test.rb diff --git a/app/models/plaid_account/investments/balance_calculator.rb b/app/models/plaid_account/investments/balance_calculator.rb index f7d72f16..ba713c19 100644 --- a/app/models/plaid_account/investments/balance_calculator.rb +++ b/app/models/plaid_account/investments/balance_calculator.rb @@ -1,13 +1,25 @@ # Plaid Investment balances have a ton of edge cases. This processor is responsible # for deriving "brokerage cash" vs. "total value" based on Plaid's reported balances and holdings. class PlaidAccount::Investments::BalanceCalculator + NegativeCashBalanceError = Class.new(StandardError) + NegativeTotalValueError = Class.new(StandardError) + def initialize(plaid_account, security_resolver:) @plaid_account = plaid_account @security_resolver = security_resolver end def balance - total_investment_account_value + total_value = total_investment_account_value + + if total_value.negative? + Sentry.capture_exception( + NegativeTotalValueError.new("Total value is negative for plaid investment account"), + level: :warning + ) + end + + total_value end # Plaid considers "brokerage cash" and "cash equivalent holdings" to all be part of "cash balance" @@ -16,7 +28,16 @@ class PlaidAccount::Investments::BalanceCalculator # For this reason, we must manually calculate the cash balance based on "total value" and "holdings value" # See PlaidAccount::Investments::SecurityResolver for more details. def cash_balance - calculate_investment_brokerage_cash + cash_balance = calculate_investment_brokerage_cash + + if cash_balance.negative? + Sentry.capture_exception( + NegativeCashBalanceError.new("Cash balance is negative for plaid investment account"), + level: :warning + ) + end + + cash_balance end private diff --git a/app/models/plaid_account/investments/security_resolver.rb b/app/models/plaid_account/investments/security_resolver.rb index bf1308da..a6b0515b 100644 --- a/app/models/plaid_account/investments/security_resolver.rb +++ b/app/models/plaid_account/investments/security_resolver.rb @@ -4,6 +4,7 @@ class PlaidAccount::Investments::SecurityResolver def initialize(plaid_account) @plaid_account = plaid_account + @security_cache = {} end # Resolves an internal Security record for a given Plaid security ID @@ -13,15 +14,6 @@ class PlaidAccount::Investments::SecurityResolver plaid_security = get_plaid_security(plaid_security_id) - unless plaid_security - report_unresolvable_security(plaid_security_id) - return Response.new(security: nil, cash_equivalent?: false) - end - - if brokerage_cash?(plaid_security) - return Response.new(security: nil, cash_equivalent?: true) - end - if plaid_security.nil? report_unresolvable_security(plaid_security_id) response = Response.new(security: nil, cash_equivalent?: false, brokerage_cash?: false) diff --git a/test/models/plaid_account/investments/balance_calculator_test.rb b/test/models/plaid_account/investments/balance_calculator_test.rb new file mode 100644 index 00000000..c4cd5d10 --- /dev/null +++ b/test/models/plaid_account/investments/balance_calculator_test.rb @@ -0,0 +1,83 @@ +require "test_helper" + +class PlaidAccount::Investments::BalanceCalculatorTest < ActiveSupport::TestCase + setup do + @plaid_account = plaid_accounts(:one) + + @plaid_account.update!( + plaid_type: "investment", + current_balance: 4000, + available_balance: 2000 # We ignore this since we have current_balance + holdings + ) + end + + test "calculates total balance from cash and positions" do + brokerage_cash_security_id = "plaid_brokerage_cash" # Plaid's brokerage cash security + cash_equivalent_security_id = "plaid_cash_equivalent" # Cash equivalent security (i.e. money market fund) + aapl_security_id = "plaid_aapl_security" # Regular stock security + + test_investments = { + transactions: [], # Irrelevant for balance calcs, leave empty + holdings: [ + # $1,000 in brokerage cash + { + security_id: brokerage_cash_security_id, + cost_basis: 1000, + institution_price: 1, + institution_value: 1000, + quantity: 1000 + }, + # $1,000 in money market funds + { + security_id: cash_equivalent_security_id, + cost_basis: 1000, + institution_price: 1, + institution_value: 1000, + quantity: 1000 + }, + # $2,000 worth of AAPL stock + { + security_id: aapl_security_id, + cost_basis: 2000, + institution_price: 200, + institution_value: 2000, + quantity: 10 + } + ], + securities: [ + { + security_id: brokerage_cash_security_id, + ticker_symbol: "CUR:USD", + is_cash_equivalent: true, + type: "cash" + }, + { + security_id: cash_equivalent_security_id, + ticker_symbol: "VMFXX", # Vanguard Money Market Reserves + is_cash_equivalent: true, + type: "mutual fund" + }, + { + security_id: aapl_security_id, + ticker_symbol: "AAPL", + is_cash_equivalent: false, + type: "equity", + market_identifier_code: "XNAS" + } + ] + } + + @plaid_account.update!(raw_investments_payload: test_investments) + + security_resolver = PlaidAccount::Investments::SecurityResolver.new(@plaid_account) + balance_calculator = PlaidAccount::Investments::BalanceCalculator.new(@plaid_account, security_resolver: security_resolver) + + # We set this equal to `current_balance` + assert_equal 4000, balance_calculator.balance + + # This is the sum of "non-brokerage-cash-holdings". In the above test case, this means + # we're summing up $2,000 of AAPL + $1,000 Vanguard MM for $3,000 in holdings value. + # We back this $3,000 from the $4,000 total to get $1,000 in cash balance. + assert_equal 1000, balance_calculator.cash_balance + end +end