mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-09 15:35:22 +02:00
Plaid balance calculator improvements
This commit is contained in:
parent
df5f926a0e
commit
43add44b06
4 changed files with 93 additions and 71 deletions
50
app/models/plaid_account/investments/balance_calculator.rb
Normal file
50
app/models/plaid_account/investments/balance_calculator.rb
Normal file
|
@ -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
|
|
@ -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
|
|
|
@ -1,5 +1,5 @@
|
||||||
# Resolves a Plaid security to an internal Security record, or nil
|
# Resolves a Plaid security to an internal Security record, or nil
|
||||||
class PlaidAccount::SecurityResolver
|
class PlaidAccount::Investments::SecurityResolver
|
||||||
UnresolvablePlaidSecurityError = Class.new(StandardError)
|
UnresolvablePlaidSecurityError = Class.new(StandardError)
|
||||||
|
|
||||||
def initialize(plaid_account)
|
def initialize(plaid_account)
|
||||||
|
@ -24,9 +24,9 @@ class PlaidAccount::SecurityResolver
|
||||||
|
|
||||||
if plaid_security.nil?
|
if plaid_security.nil?
|
||||||
report_unresolvable_security(plaid_security_id)
|
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)
|
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
|
else
|
||||||
security = Security::Resolver.new(
|
security = Security::Resolver.new(
|
||||||
plaid_security["ticker_symbol"],
|
plaid_security["ticker_symbol"],
|
||||||
|
@ -35,7 +35,8 @@ class PlaidAccount::SecurityResolver
|
||||||
|
|
||||||
response = Response.new(
|
response = Response.new(
|
||||||
security: security,
|
security: security,
|
||||||
cash_equivalent?: cash_equivalent?(plaid_security)
|
cash_equivalent?: cash_equivalent?(plaid_security),
|
||||||
|
brokerage_cash?: false
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -47,7 +48,7 @@ class PlaidAccount::SecurityResolver
|
||||||
private
|
private
|
||||||
attr_reader :plaid_account, :security_cache
|
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
|
def securities
|
||||||
plaid_account.raw_investments_payload["securities"] || []
|
plaid_account.raw_investments_payload["securities"] || []
|
||||||
|
@ -62,16 +63,6 @@ class PlaidAccount::SecurityResolver
|
||||||
securities.find { |s| s["proxy_security_id"] == plaid_security_id }
|
securities.find { |s| s["proxy_security_id"] == plaid_security_id }
|
||||||
end
|
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)
|
def report_unresolvable_security(plaid_security_id)
|
||||||
Sentry.capture_exception(UnresolvablePlaidSecurityError.new("Could not resolve Plaid security from provided data")) do |scope|
|
Sentry.capture_exception(UnresolvablePlaidSecurityError.new("Could not resolve Plaid security from provided data")) do |scope|
|
||||||
scope.set_context("plaid_security", {
|
scope.set_context("plaid_security", {
|
||||||
|
@ -79,4 +70,32 @@ class PlaidAccount::SecurityResolver
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
@ -44,9 +44,9 @@ class PlaidAccount::Processor
|
||||||
account.assign_attributes(
|
account.assign_attributes(
|
||||||
accountable: map_accountable(plaid_account.plaid_type),
|
accountable: map_accountable(plaid_account.plaid_type),
|
||||||
subtype: map_subtype(plaid_account.plaid_type, plaid_account.plaid_subtype),
|
subtype: map_subtype(plaid_account.plaid_type, plaid_account.plaid_subtype),
|
||||||
balance: balance,
|
balance: balance_calculator.balance,
|
||||||
currency: plaid_account.currency,
|
currency: plaid_account.currency,
|
||||||
cash_balance: cash_balance
|
cash_balance: balance_calculator.cash_balance
|
||||||
)
|
)
|
||||||
|
|
||||||
account.save!
|
account.save!
|
||||||
|
@ -78,28 +78,17 @@ class PlaidAccount::Processor
|
||||||
report_exception(e)
|
report_exception(e)
|
||||||
end
|
end
|
||||||
|
|
||||||
def balance
|
def balance_calculator
|
||||||
case plaid_account.plaid_type
|
if plaid_account.plaid_type == "investment"
|
||||||
when "investment"
|
@balance_calculator ||= PlaidAccount::Investments::BalanceCalculator.new(plaid_account, security_resolver: security_resolver)
|
||||||
investment_balance_processor.balance
|
|
||||||
else
|
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
|
||||||
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)
|
def report_exception(error)
|
||||||
Sentry.capture_exception(error) do |scope|
|
Sentry.capture_exception(error) do |scope|
|
||||||
scope.set_tags(plaid_account_id: plaid_account.id)
|
scope.set_tags(plaid_account_id: plaid_account.id)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue