diff --git a/app/models/plaid_account/investments/balance_calculator.rb b/app/models/plaid_account/investments/balance_calculator.rb new file mode 100644 index 00000000..f7d72f16 --- /dev/null +++ b/app/models/plaid_account/investments/balance_calculator.rb @@ -0,0 +1,50 @@ +# 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 + def initialize(plaid_account, security_resolver:) + @plaid_account = plaid_account + @security_resolver = security_resolver + end + + def balance + total_investment_account_value + end + + # Plaid considers "brokerage cash" and "cash equivalent holdings" to all be part of "cash balance" + # + # Internally, we DO NOT. Maybe clearly distinguishes between "brokerage cash" vs. "holdings (i.e. invested cash)" + # 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 + end + + private + attr_reader :plaid_account, :security_resolver + + def holdings + plaid_account.raw_investments_payload["holdings"] || [] + end + + def calculate_investment_brokerage_cash + total_investment_account_value - true_holdings_value + end + + # This is our source of truth. We assume Plaid's `current_balance` reporting is 100% accurate + # Plaid guarantees `current_balance` AND/OR `available_balance` is always present, and based on the docs, + # `current_balance` should represent "total account value". + def total_investment_account_value + plaid_account.current_balance || plaid_account.available_balance + end + + # Plaid holdings summed up, LESS "brokerage cash" holdings (that we've manually identified) + def true_holdings_value + # True holdings are holdings *less* Plaid's "pseudo-securities" (e.g. `CUR:USD` brokerage cash "holding") + true_holdings = holdings.reject do |h| + security = security_resolver.resolve(plaid_security_id: h["security_id"]) + security.brokerage_cash? + end + + true_holdings.sum { |h| h["quantity"] * h["institution_price"] } + end +end diff --git a/app/models/plaid_account/investments/balance_processor.rb b/app/models/plaid_account/investments/balance_processor.rb deleted file mode 100644 index 712c2c22..00000000 --- a/app/models/plaid_account/investments/balance_processor.rb +++ /dev/null @@ -1,36 +0,0 @@ -# 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::BalanceProcessor - attr_reader :plaid_account, :security_resolver - - def initialize(plaid_account, security_resolver:) - @plaid_account = plaid_account - @security_resolver = security_resolver - end - - def balance - plaid_account.current_balance || plaid_account.available_balance - end - - # Plaid considers "brokerage cash" and "cash equivalent holdings" to all be part of "cash balance" - # Internally, we DO NOT. - # Maybe clearly distinguishes between "brokerage cash" vs. "holdings (i.e. invested cash)" - # For this reason, we must back out cash + cash equivalent holdings from the reported cash balance to avoid double counting - def cash_balance - plaid_account.available_balance - excludable_cash_holdings_value - end - - private - def holdings - plaid_account.raw_investments_payload["holdings"] - end - - def excludable_cash_holdings_value - excludable_cash_holdings = holdings.select do |h| - response = security_resolver.resolve(plaid_security_id: h["security_id"]) - response.security.present? && response.cash_equivalent? - end - - excludable_cash_holdings.sum { |h| h["quantity"] * h["institution_price"] } - end -end diff --git a/app/models/plaid_account/investments/security_resolver.rb b/app/models/plaid_account/investments/security_resolver.rb index 3d544036..bf1308da 100644 --- a/app/models/plaid_account/investments/security_resolver.rb +++ b/app/models/plaid_account/investments/security_resolver.rb @@ -1,5 +1,5 @@ # Resolves a Plaid security to an internal Security record, or nil -class PlaidAccount::SecurityResolver +class PlaidAccount::Investments::SecurityResolver UnresolvablePlaidSecurityError = Class.new(StandardError) def initialize(plaid_account) @@ -24,9 +24,9 @@ class PlaidAccount::SecurityResolver if plaid_security.nil? report_unresolvable_security(plaid_security_id) - response = Response.new(security: nil, cash_equivalent?: false) + response = Response.new(security: nil, cash_equivalent?: false, brokerage_cash?: false) elsif brokerage_cash?(plaid_security) - response = Response.new(security: nil, cash_equivalent?: true) + response = Response.new(security: nil, cash_equivalent?: true, brokerage_cash?: true) else security = Security::Resolver.new( plaid_security["ticker_symbol"], @@ -35,7 +35,8 @@ class PlaidAccount::SecurityResolver response = Response.new( security: security, - cash_equivalent?: cash_equivalent?(plaid_security) + cash_equivalent?: cash_equivalent?(plaid_security), + brokerage_cash?: false ) end @@ -47,7 +48,7 @@ class PlaidAccount::SecurityResolver private attr_reader :plaid_account, :security_cache - Response = Struct.new(:security, :cash_equivalent?, keyword_init: true) + Response = Struct.new(:security, :cash_equivalent?, :brokerage_cash?, keyword_init: true) def securities plaid_account.raw_investments_payload["securities"] || [] @@ -62,16 +63,6 @@ class PlaidAccount::SecurityResolver securities.find { |s| s["proxy_security_id"] == plaid_security_id } end - # We ignore these. Plaid calls these "holdings", but they are "brokerage cash" (treated separately in our system) - def brokerage_cash?(plaid_security) - [ "CUR:USD" ].include?(plaid_security["ticker_symbol"]) - end - - # These are valid holdings, but we use this designation to calculate the cash value of the account - def cash_equivalent?(plaid_security) - plaid_security["type"] == "cash" || plaid_security["is_cash_equivalent"] == true - end - def report_unresolvable_security(plaid_security_id) Sentry.capture_exception(UnresolvablePlaidSecurityError.new("Could not resolve Plaid security from provided data")) do |scope| scope.set_context("plaid_security", { @@ -79,4 +70,32 @@ class PlaidAccount::SecurityResolver }) end end + + # Plaid treats "brokerage cash" differently than us. Internally, Maybe treats "brokerage cash" + # as "uninvested cash" (i.e. cash that doesn't have a corresponding Security and can be withdrawn). + # + # Plaid treats everything as a "holding" with a corresponding Security. For example, "brokerage cash" (USD) + # in Plaids data model would be represented as: + # + # - A Security with ticker `CUR:USD` + # - A holding, linked to the `CUR:USD` Security, with an institution price of $1 + # + # Internally, we store brokerage cash balance as `account.cash_balance`, NOT as a holding + security. + # This allows us to properly build historical cash balances and holdings values separately and accurately. + # + # These help identify these "special case" securities for various calculations. + # + def known_plaid_brokerage_cash_tickers + [ "CUR:USD" ] + end + + def brokerage_cash?(plaid_security) + return false unless plaid_security["ticker_symbol"].present? + known_plaid_brokerage_cash_tickers.include?(plaid_security["ticker_symbol"]) + end + + def cash_equivalent?(plaid_security) + return false unless plaid_security["type"].present? + plaid_security["type"] == "cash" || plaid_security["is_cash_equivalent"] == true + end end diff --git a/app/models/plaid_account/processor.rb b/app/models/plaid_account/processor.rb index 1e81888d..5db0e470 100644 --- a/app/models/plaid_account/processor.rb +++ b/app/models/plaid_account/processor.rb @@ -44,9 +44,9 @@ class PlaidAccount::Processor account.assign_attributes( accountable: map_accountable(plaid_account.plaid_type), subtype: map_subtype(plaid_account.plaid_type, plaid_account.plaid_subtype), - balance: balance, + balance: balance_calculator.balance, currency: plaid_account.currency, - cash_balance: cash_balance + cash_balance: balance_calculator.cash_balance ) account.save! @@ -78,28 +78,17 @@ class PlaidAccount::Processor report_exception(e) end - def balance - case plaid_account.plaid_type - when "investment" - investment_balance_processor.balance + def balance_calculator + if plaid_account.plaid_type == "investment" + @balance_calculator ||= PlaidAccount::Investments::BalanceCalculator.new(plaid_account, security_resolver: security_resolver) else - plaid_account.current_balance || plaid_account.available_balance + OpenStruct.new( + balance: plaid_account.current_balance || plaid_account.available_balance, + cash_balance: plaid_account.available_balance || 0 + ) end end - def cash_balance - case plaid_account.plaid_type - when "investment" - investment_balance_processor.cash_balance - else - plaid_account.available_balance || 0 - end - end - - def investment_balance_processor - PlaidAccount::Investments::BalanceProcessor.new(plaid_account, security_resolver: security_resolver) - end - def report_exception(error) Sentry.capture_exception(error) do |scope| scope.set_tags(plaid_account_id: plaid_account.id)