mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-09 07:25:19 +02:00
complete plaid processors first pass
This commit is contained in:
parent
d8b44ae937
commit
df5f926a0e
22 changed files with 490 additions and 414 deletions
|
@ -1,6 +1,10 @@
|
||||||
class CreditCard < ApplicationRecord
|
class CreditCard < ApplicationRecord
|
||||||
include Accountable
|
include Accountable
|
||||||
|
|
||||||
|
SUBTYPES = {
|
||||||
|
"credit_card" => { short: "Credit Card", long: "Credit Card" }
|
||||||
|
}.freeze
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
def color
|
def color
|
||||||
"#F13636"
|
"#F13636"
|
||||||
|
|
|
@ -3,7 +3,10 @@ class Depository < ApplicationRecord
|
||||||
|
|
||||||
SUBTYPES = {
|
SUBTYPES = {
|
||||||
"checking" => { short: "Checking", long: "Checking" },
|
"checking" => { short: "Checking", long: "Checking" },
|
||||||
"savings" => { short: "Savings", long: "Savings" }
|
"savings" => { short: "Savings", long: "Savings" },
|
||||||
|
"hsa" => { short: "HSA", long: "Health Savings Account" },
|
||||||
|
"cd" => { short: "CD", long: "Certificate of Deposit" },
|
||||||
|
"money_market" => { short: "MM", long: "Money Market" }
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
|
|
|
@ -6,12 +6,11 @@ class Investment < ApplicationRecord
|
||||||
"pension" => { short: "Pension", long: "Pension" },
|
"pension" => { short: "Pension", long: "Pension" },
|
||||||
"retirement" => { short: "Retirement", long: "Retirement" },
|
"retirement" => { short: "Retirement", long: "Retirement" },
|
||||||
"401k" => { short: "401(k)", long: "401(k)" },
|
"401k" => { short: "401(k)", long: "401(k)" },
|
||||||
"traditional_401k" => { short: "Traditional 401(k)", long: "Traditional 401(k)" },
|
|
||||||
"roth_401k" => { short: "Roth 401(k)", long: "Roth 401(k)" },
|
"roth_401k" => { short: "Roth 401(k)", long: "Roth 401(k)" },
|
||||||
"529_plan" => { short: "529 Plan", long: "529 Plan" },
|
"529_plan" => { short: "529 Plan", long: "529 Plan" },
|
||||||
"hsa" => { short: "HSA", long: "Health Savings Account" },
|
"hsa" => { short: "HSA", long: "Health Savings Account" },
|
||||||
"mutual_fund" => { short: "Mutual Fund", long: "Mutual Fund" },
|
"mutual_fund" => { short: "Mutual Fund", long: "Mutual Fund" },
|
||||||
"traditional_ira" => { short: "Traditional IRA", long: "Traditional IRA" },
|
"ira" => { short: "IRA", long: "Traditional IRA" },
|
||||||
"roth_ira" => { short: "Roth IRA", long: "Roth IRA" },
|
"roth_ira" => { short: "Roth IRA", long: "Roth IRA" },
|
||||||
"angel" => { short: "Angel", long: "Angel" }
|
"angel" => { short: "Angel", long: "Angel" }
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
|
@ -1,6 +1,13 @@
|
||||||
class Loan < ApplicationRecord
|
class Loan < ApplicationRecord
|
||||||
include Accountable
|
include Accountable
|
||||||
|
|
||||||
|
SUBTYPES = {
|
||||||
|
"mortgage" => { short: "Mortgage", long: "Mortgage" },
|
||||||
|
"student" => { short: "Student", long: "Student Loan" },
|
||||||
|
"auto" => { short: "Auto", long: "Auto Loan" },
|
||||||
|
"other" => { short: "Other", long: "Other Loan" }
|
||||||
|
}.freeze
|
||||||
|
|
||||||
def monthly_payment
|
def monthly_payment
|
||||||
return nil if term_months.nil? || interest_rate.nil? || rate_type.nil? || rate_type != "fixed"
|
return nil if term_months.nil? || interest_rate.nil? || rate_type.nil? || rate_type != "fixed"
|
||||||
return Money.new(0, account.currency) if account.loan.original_balance.amount.zero? || term_months.zero?
|
return Money.new(0, account.currency) if account.loan.original_balance.amount.zero? || term_months.zero?
|
||||||
|
|
|
@ -1,16 +0,0 @@
|
||||||
class PlaidAccount::CreditLiabilityProcessor
|
|
||||||
def initialize(plaid_account)
|
|
||||||
@plaid_account = plaid_account
|
|
||||||
end
|
|
||||||
|
|
||||||
def process
|
|
||||||
puts "processing credit liability!"
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
attr_reader :plaid_account
|
|
||||||
|
|
||||||
def account
|
|
||||||
plaid_account.account
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,71 +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::InvestmentBalanceProcessor
|
|
||||||
include PlaidAccount::Securitizable
|
|
||||||
|
|
||||||
attr_reader :plaid_account
|
|
||||||
|
|
||||||
def initialize(plaid_account)
|
|
||||||
@plaid_account = plaid_account
|
|
||||||
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|
|
|
||||||
internal_security, plaid_security = get_security(h["security_id"])
|
|
||||||
|
|
||||||
return false unless plaid_security.present?
|
|
||||||
|
|
||||||
plaid_security_is_cash_equivalent = plaid_security["is_cash_equivalent"] || plaid_security["type"] == "cash"
|
|
||||||
|
|
||||||
internal_security.present? && plaid_security_is_cash_equivalent
|
|
||||||
end
|
|
||||||
|
|
||||||
excludable_cash_holdings.sum { |h| h["quantity"] * h["institution_price"] }
|
|
||||||
end
|
|
||||||
|
|
||||||
def securities
|
|
||||||
plaid_account.raw_investments_payload["securities"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_security(plaid_security_id)
|
|
||||||
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?
|
|
||||||
return [ nil, plaid_security ] if plaid_security["ticker_symbol"] == "CUR:USD" # internally, we do not consider cash a security and track it separately
|
|
||||||
|
|
||||||
operating_mic = plaid_security["market_identifier_code"]
|
|
||||||
|
|
||||||
# Find any matching security
|
|
||||||
security = Security.find_or_create_by!(
|
|
||||||
ticker: plaid_security["ticker_symbol"]&.upcase,
|
|
||||||
exchange_operating_mic: operating_mic&.upcase
|
|
||||||
)
|
|
||||||
|
|
||||||
[ security, plaid_security ]
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,18 +0,0 @@
|
||||||
class PlaidAccount::InvestmentHoldingsProcessor
|
|
||||||
include PlaidAccount::Securitizable
|
|
||||||
|
|
||||||
def initialize(plaid_account)
|
|
||||||
@plaid_account = plaid_account
|
|
||||||
end
|
|
||||||
|
|
||||||
def process
|
|
||||||
puts "processing investment holdings!"
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
attr_reader :plaid_account
|
|
||||||
|
|
||||||
def account
|
|
||||||
plaid_account.account
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,17 +0,0 @@
|
||||||
class PlaidAccount::InvestmentTransactionsProcessor
|
|
||||||
include PlaidAccount::Securitizable
|
|
||||||
|
|
||||||
def initialize(plaid_account)
|
|
||||||
@plaid_account = plaid_account
|
|
||||||
end
|
|
||||||
|
|
||||||
def process
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
attr_reader :plaid_account
|
|
||||||
|
|
||||||
def account
|
|
||||||
plaid_account.account
|
|
||||||
end
|
|
||||||
end
|
|
36
app/models/plaid_account/investments/balance_processor.rb
Normal file
36
app/models/plaid_account/investments/balance_processor.rb
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
# 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
|
41
app/models/plaid_account/investments/holdings_processor.rb
Normal file
41
app/models/plaid_account/investments/holdings_processor.rb
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
class PlaidAccount::Investments::HoldingsProcessor
|
||||||
|
include PlaidAccount::Securitizable
|
||||||
|
|
||||||
|
def initialize(plaid_account, security_resolver:)
|
||||||
|
@plaid_account = plaid_account
|
||||||
|
@security_resolver = security_resolver
|
||||||
|
end
|
||||||
|
|
||||||
|
def process
|
||||||
|
holdings.each do |plaid_holding|
|
||||||
|
resolved_security_result = security_resolver.resolve(plaid_security_id: plaid_holding["security_id"])
|
||||||
|
|
||||||
|
return unless resolved_security_result.security.present?
|
||||||
|
|
||||||
|
holding = account.holdings.find_or_initialize_by(
|
||||||
|
security: resolved_security_result.security,
|
||||||
|
date: Date.current,
|
||||||
|
currency: plaid_holding["iso_currency_code"]
|
||||||
|
)
|
||||||
|
|
||||||
|
holding.assign_attributes(
|
||||||
|
qty: plaid_holding["quantity"],
|
||||||
|
price: plaid_holding["institution_price"],
|
||||||
|
amount: plaid_holding["quantity"] * plaid_holding["institution_price"]
|
||||||
|
)
|
||||||
|
|
||||||
|
holding.save!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
attr_reader :plaid_account
|
||||||
|
|
||||||
|
def account
|
||||||
|
plaid_account.account
|
||||||
|
end
|
||||||
|
|
||||||
|
def holdings
|
||||||
|
plaid_account.raw_investments_payload["holdings"] || []
|
||||||
|
end
|
||||||
|
end
|
82
app/models/plaid_account/investments/security_resolver.rb
Normal file
82
app/models/plaid_account/investments/security_resolver.rb
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
# Resolves a Plaid security to an internal Security record, or nil
|
||||||
|
class PlaidAccount::SecurityResolver
|
||||||
|
UnresolvablePlaidSecurityError = Class.new(StandardError)
|
||||||
|
|
||||||
|
def initialize(plaid_account)
|
||||||
|
@plaid_account = plaid_account
|
||||||
|
end
|
||||||
|
|
||||||
|
# Resolves an internal Security record for a given Plaid security ID
|
||||||
|
def resolve(plaid_security_id:)
|
||||||
|
response = @security_cache[plaid_security_id]
|
||||||
|
return response if response.present?
|
||||||
|
|
||||||
|
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)
|
||||||
|
elsif brokerage_cash?(plaid_security)
|
||||||
|
response = Response.new(security: nil, cash_equivalent?: true)
|
||||||
|
else
|
||||||
|
security = Security::Resolver.new(
|
||||||
|
plaid_security["ticker_symbol"],
|
||||||
|
exchange_operating_mic: plaid_security["market_identifier_code"]
|
||||||
|
).resolve
|
||||||
|
|
||||||
|
response = Response.new(
|
||||||
|
security: security,
|
||||||
|
cash_equivalent?: cash_equivalent?(plaid_security)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
@security_cache[plaid_security_id] = response
|
||||||
|
|
||||||
|
response
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
attr_reader :plaid_account, :security_cache
|
||||||
|
|
||||||
|
Response = Struct.new(:security, :cash_equivalent?, keyword_init: true)
|
||||||
|
|
||||||
|
def securities
|
||||||
|
plaid_account.raw_investments_payload["securities"] || []
|
||||||
|
end
|
||||||
|
|
||||||
|
# Tries to find security, or returns the "proxy security" (common with options contracts that have underlying securities)
|
||||||
|
def get_plaid_security(plaid_security_id)
|
||||||
|
security = securities.find { |s| s["security_id"] == plaid_security_id && s["ticker_symbol"].present? }
|
||||||
|
|
||||||
|
return security if security.present?
|
||||||
|
|
||||||
|
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", {
|
||||||
|
plaid_security_id: plaid_security_id
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,78 @@
|
||||||
|
class PlaidAccount::Investments::TransactionsProcessor
|
||||||
|
include PlaidAccount::Securitizable
|
||||||
|
|
||||||
|
def initialize(plaid_account, security_resolver:)
|
||||||
|
@plaid_account = plaid_account
|
||||||
|
@security_resolver = security_resolver
|
||||||
|
end
|
||||||
|
|
||||||
|
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
|
||||||
|
find_or_create_cash_entry(transaction)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
attr_reader :plaid_account, :security_resolver
|
||||||
|
|
||||||
|
def account
|
||||||
|
plaid_account.account
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_or_create_trade_entry(transaction)
|
||||||
|
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"],
|
||||||
|
date: transaction["date"]
|
||||||
|
)
|
||||||
|
|
||||||
|
entry.trade.assign_attributes(
|
||||||
|
security: security,
|
||||||
|
qty: transaction["quantity"],
|
||||||
|
price: transaction["price"],
|
||||||
|
currency: transaction["iso_currency_code"]
|
||||||
|
)
|
||||||
|
|
||||||
|
entry.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_or_create_cash_entry(transaction)
|
||||||
|
entry = account.entries.find_or_initialize_by(plaid_id: transaction["investment_transaction_id"]) do |e|
|
||||||
|
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.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
def transactions
|
||||||
|
plaid_account.raw_investments_payload["transactions"] || []
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,131 +0,0 @@
|
||||||
class PlaidAccount::InvestmentsProcessor
|
|
||||||
attr_reader :plaid_account
|
|
||||||
|
|
||||||
def initialize(plaid_account)
|
|
||||||
@plaid_account = plaid_account
|
|
||||||
end
|
|
||||||
|
|
||||||
def process
|
|
||||||
puts "processing investments!"
|
|
||||||
transactions.each do |transaction|
|
|
||||||
process_investment_transaction(transaction)
|
|
||||||
end
|
|
||||||
|
|
||||||
holdings.each do |holding|
|
|
||||||
process_holding(holding)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
def account
|
|
||||||
plaid_account.account
|
|
||||||
end
|
|
||||||
|
|
||||||
def process_investment_transaction(transaction)
|
|
||||||
security, plaid_security = get_security(transaction["security_id"])
|
|
||||||
|
|
||||||
return if security.nil?
|
|
||||||
|
|
||||||
if transaction["type"] == "cash" || plaid_security["ticker_symbol"] == "CUR:USD"
|
|
||||||
entry = account.entries.find_or_initialize_by(plaid_id: transaction["investment_transaction_id"]) do |e|
|
|
||||||
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.save!
|
|
||||||
else
|
|
||||||
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"],
|
|
||||||
date: transaction["date"]
|
|
||||||
)
|
|
||||||
|
|
||||||
entry.trade.assign_attributes(
|
|
||||||
security: security,
|
|
||||||
qty: transaction["quantity"],
|
|
||||||
price: transaction["price"],
|
|
||||||
currency: transaction["iso_currency_code"]
|
|
||||||
)
|
|
||||||
|
|
||||||
entry.save!
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def process_holding(plaid_holding)
|
|
||||||
internal_security, _plaid_security = get_security(plaid_holding["security_id"])
|
|
||||||
|
|
||||||
return if internal_security.nil?
|
|
||||||
|
|
||||||
holding = account.holdings.find_or_initialize_by(
|
|
||||||
security: internal_security,
|
|
||||||
date: Date.current,
|
|
||||||
currency: plaid_holding["iso_currency_code"]
|
|
||||||
)
|
|
||||||
|
|
||||||
holding.assign_attributes(
|
|
||||||
qty: plaid_holding["quantity"],
|
|
||||||
price: plaid_holding["institution_price"],
|
|
||||||
amount: plaid_holding["quantity"] * plaid_holding["institution_price"]
|
|
||||||
)
|
|
||||||
|
|
||||||
holding.save!
|
|
||||||
end
|
|
||||||
|
|
||||||
def transactions
|
|
||||||
plaid_account.raw_investments_payload["transactions"] || []
|
|
||||||
end
|
|
||||||
|
|
||||||
def holdings
|
|
||||||
plaid_account.raw_investments_payload["holdings"] || []
|
|
||||||
end
|
|
||||||
|
|
||||||
def securities
|
|
||||||
plaid_account.raw_investments_payload["securities"] || []
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_security(plaid_security_id)
|
|
||||||
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?
|
|
||||||
return [ nil, plaid_security ] if plaid_security["ticker_symbol"] == "CUR:USD" # internally, we do not consider cash a security and track it separately
|
|
||||||
|
|
||||||
operating_mic = plaid_security["market_identifier_code"]
|
|
||||||
|
|
||||||
# Find any matching security
|
|
||||||
security = Security.find_or_create_by!(
|
|
||||||
ticker: plaid_security["ticker_symbol"]&.upcase,
|
|
||||||
exchange_operating_mic: operating_mic&.upcase
|
|
||||||
)
|
|
||||||
|
|
||||||
[ security, plaid_security ]
|
|
||||||
end
|
|
||||||
end
|
|
25
app/models/plaid_account/liabilities/credit_processor.rb
Normal file
25
app/models/plaid_account/liabilities/credit_processor.rb
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
class PlaidAccount::Liabilities::CreditProcessor
|
||||||
|
def initialize(plaid_account)
|
||||||
|
@plaid_account = plaid_account
|
||||||
|
end
|
||||||
|
|
||||||
|
def process
|
||||||
|
return unless credit_data.present?
|
||||||
|
|
||||||
|
account.credit_card.update!(
|
||||||
|
minimum_payment: credit_data.dig("minimum_payment_amount"),
|
||||||
|
apr: credit_data.dig("aprs", 0, "apr_percentage")
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
attr_reader :plaid_account
|
||||||
|
|
||||||
|
def account
|
||||||
|
plaid_account.account
|
||||||
|
end
|
||||||
|
|
||||||
|
def credit_data
|
||||||
|
plaid_account.raw_liabilities_payload["credit"]
|
||||||
|
end
|
||||||
|
end
|
25
app/models/plaid_account/liabilities/mortgage_processor.rb
Normal file
25
app/models/plaid_account/liabilities/mortgage_processor.rb
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
class PlaidAccount::Liabilities::MortgageProcessor
|
||||||
|
def initialize(plaid_account)
|
||||||
|
@plaid_account = plaid_account
|
||||||
|
end
|
||||||
|
|
||||||
|
def process
|
||||||
|
return unless mortgage_data.present?
|
||||||
|
|
||||||
|
account.loan.update!(
|
||||||
|
rate_type: mortgage_data.dig("interest_rate", "type"),
|
||||||
|
interest_rate: mortgage_data.dig("interest_rate", "percentage")
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
attr_reader :plaid_account
|
||||||
|
|
||||||
|
def account
|
||||||
|
plaid_account.account
|
||||||
|
end
|
||||||
|
|
||||||
|
def mortgage_data
|
||||||
|
plaid_account.raw_liabilities_payload["mortgage"]
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,41 @@
|
||||||
|
class PlaidAccount::Liabilities::StudentLoanProcessor
|
||||||
|
def initialize(plaid_account)
|
||||||
|
@plaid_account = plaid_account
|
||||||
|
end
|
||||||
|
|
||||||
|
def process
|
||||||
|
return unless student_loan_data.present?
|
||||||
|
|
||||||
|
account.loan.update!(
|
||||||
|
rate_type: "fixed",
|
||||||
|
interest_rate: student_loan_data["interest_rate_percentage"],
|
||||||
|
initial_balance: student_loan_data["origination_principal_amount"],
|
||||||
|
term_months: term_months
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
attr_reader :plaid_account
|
||||||
|
|
||||||
|
def account
|
||||||
|
plaid_account.account
|
||||||
|
end
|
||||||
|
|
||||||
|
def term_months
|
||||||
|
return nil unless origination_date && expected_payoff_date
|
||||||
|
|
||||||
|
(expected_payoff_date - origination_date).to_i / 30
|
||||||
|
end
|
||||||
|
|
||||||
|
def origination_date
|
||||||
|
student_loan_data["origination_date"]
|
||||||
|
end
|
||||||
|
|
||||||
|
def expected_payoff_date
|
||||||
|
student_loan_data["expected_payoff_date"]
|
||||||
|
end
|
||||||
|
|
||||||
|
def student_loan_data
|
||||||
|
plaid_account.raw_liabilities_payload["student"]
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,55 +0,0 @@
|
||||||
class PlaidAccount::LiabilitiesProcessor
|
|
||||||
attr_reader :plaid_account
|
|
||||||
|
|
||||||
def initialize(plaid_account)
|
|
||||||
@plaid_account = plaid_account
|
|
||||||
end
|
|
||||||
|
|
||||||
def process
|
|
||||||
if account.credit_card? && credit_data.present?
|
|
||||||
account.credit_card.update!(
|
|
||||||
minimum_payment: credit_data.dig("minimum_payment_amount"),
|
|
||||||
apr: credit_data.dig("aprs", 0, "apr_percentage")
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
if account.loan? && mortgage_data.present?
|
|
||||||
account.loan.update!(
|
|
||||||
rate_type: mortgage_data.dig("interest_rate", "type"),
|
|
||||||
interest_rate: mortgage_data.dig("interest_rate", "percentage")
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
if account.loan? && student_loan_data.present?
|
|
||||||
term_months = if student_loan_data["origination_date"] && student_loan_data["expected_payoff_date"]
|
|
||||||
(student_loan_data["expected_payoff_date"] - student_loan_data["origination_date"]).to_i / 30
|
|
||||||
else
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
account.loan.update!(
|
|
||||||
rate_type: "fixed",
|
|
||||||
interest_rate: student_loan_data["interest_rate_percentage"],
|
|
||||||
initial_balance: student_loan_data["origination_principal_amount"],
|
|
||||||
term_months: term_months
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
def account
|
|
||||||
plaid_account.account
|
|
||||||
end
|
|
||||||
|
|
||||||
def credit_data
|
|
||||||
plaid_account.raw_liabilities_payload["credit"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def mortgage_data
|
|
||||||
plaid_account.raw_liabilities_payload["mortgage"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def student_loan_data
|
|
||||||
plaid_account.raw_liabilities_payload["student"]
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,22 +1,34 @@
|
||||||
class PlaidAccount::Processor
|
class PlaidAccount::Processor
|
||||||
|
include PlaidAccount::TypeMappable
|
||||||
|
|
||||||
attr_reader :plaid_account
|
attr_reader :plaid_account
|
||||||
|
|
||||||
UnknownAccountTypeError = Class.new(StandardError)
|
|
||||||
|
|
||||||
# Plaid Account Types -> Accountable Types
|
|
||||||
TYPE_MAPPING = {
|
|
||||||
"depository" => Depository,
|
|
||||||
"credit" => CreditCard,
|
|
||||||
"loan" => Loan,
|
|
||||||
"investment" => Investment,
|
|
||||||
"other" => OtherAsset
|
|
||||||
}
|
|
||||||
|
|
||||||
def initialize(plaid_account)
|
def initialize(plaid_account)
|
||||||
@plaid_account = plaid_account
|
@plaid_account = plaid_account
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Each step represents a different Plaid API endpoint / "product"
|
||||||
|
#
|
||||||
|
# Processing the account is the first step and if it fails, we halt the entire processor
|
||||||
|
# Each subsequent step can fail independently, but we continue processing the rest of the steps
|
||||||
def process
|
def process
|
||||||
|
process_account!
|
||||||
|
process_transactions
|
||||||
|
process_investments
|
||||||
|
process_liabilities
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def family
|
||||||
|
plaid_account.plaid_item.family
|
||||||
|
end
|
||||||
|
|
||||||
|
# Shared securities reader and resolver
|
||||||
|
def security_resolver
|
||||||
|
@security_resolver ||= PlaidAccount::Investments::SecurityResolver.new(plaid_account)
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_account!
|
||||||
PlaidAccount.transaction do
|
PlaidAccount.transaction do
|
||||||
account = family.accounts.find_or_initialize_by(
|
account = family.accounts.find_or_initialize_by(
|
||||||
plaid_account_id: plaid_account.id
|
plaid_account_id: plaid_account.id
|
||||||
|
@ -30,7 +42,8 @@ class PlaidAccount::Processor
|
||||||
)
|
)
|
||||||
|
|
||||||
account.assign_attributes(
|
account.assign_attributes(
|
||||||
accountable: accountable,
|
accountable: map_accountable(plaid_account.plaid_type),
|
||||||
|
subtype: map_subtype(plaid_account.plaid_type, plaid_account.plaid_subtype),
|
||||||
balance: balance,
|
balance: balance,
|
||||||
currency: plaid_account.currency,
|
currency: plaid_account.currency,
|
||||||
cash_balance: cash_balance
|
cash_balance: cash_balance
|
||||||
|
@ -38,23 +51,31 @@ class PlaidAccount::Processor
|
||||||
|
|
||||||
account.save!
|
account.save!
|
||||||
end
|
end
|
||||||
|
|
||||||
PlaidAccount::TransactionsProcessor.new(plaid_account).process
|
|
||||||
PlaidAccount::InvestmentsProcessor.new(plaid_account).process
|
|
||||||
PlaidAccount::LiabilitiesProcessor.new(plaid_account).process
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
def process_transactions
|
||||||
def family
|
PlaidAccount::Transactions::Processor.new(plaid_account).process
|
||||||
plaid_account.plaid_item.family
|
rescue => e
|
||||||
|
report_exception(e)
|
||||||
end
|
end
|
||||||
|
|
||||||
def accountable
|
def process_investments
|
||||||
accountable_class = TYPE_MAPPING[plaid_account.plaid_type]
|
PlaidAccount::Investments::TransactionsProcessor.new(plaid_account, security_resolver: security_resolver).process
|
||||||
|
PlaidAccount::Investments::HoldingsProcessor.new(plaid_account, security_resolver: security_resolver).process
|
||||||
|
report_exception(e)
|
||||||
|
end
|
||||||
|
|
||||||
raise UnknownAccountTypeError, "Unknown account type: #{plaid_account.plaid_type}" unless accountable_class
|
def process_liabilities
|
||||||
|
case [ plaid_account.plaid_type, plaid_account.plaid_subtype ]
|
||||||
accountable_class.new
|
when [ "credit", "credit card" ]
|
||||||
|
PlaidAccount::CreditLiabilityProcessor.new(plaid_account).process
|
||||||
|
when [ "loan", "mortgage" ]
|
||||||
|
PlaidAccount::MortgageLiabilityProcessor.new(plaid_account).process
|
||||||
|
when [ "loan", "student" ]
|
||||||
|
PlaidAccount::StudentLoanLiabilityProcessor.new(plaid_account).process
|
||||||
|
end
|
||||||
|
rescue => e
|
||||||
|
report_exception(e)
|
||||||
end
|
end
|
||||||
|
|
||||||
def balance
|
def balance
|
||||||
|
@ -76,6 +97,12 @@ class PlaidAccount::Processor
|
||||||
end
|
end
|
||||||
|
|
||||||
def investment_balance_processor
|
def investment_balance_processor
|
||||||
PlaidAccount::InvestmentBalanceProcessor.new(plaid_account)
|
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)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,39 +0,0 @@
|
||||||
module PlaidAccount::Securitizable
|
|
||||||
extend ActiveSupport::Concern
|
|
||||||
|
|
||||||
# TODO
|
|
||||||
def get_security(plaid_security_id)
|
|
||||||
plaid_security = get_plaid_security(plaid_security_id)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return [ nil, nil ] if plaid_security.nil? || plaid_security["ticker_symbol"].blank?
|
|
||||||
return [ nil, plaid_security ] if plaid_security["ticker_symbol"] == "CUR:USD" # internally, we do not consider cash a security and track it separately
|
|
||||||
|
|
||||||
operating_mic = plaid_security["market_identifier_code"]
|
|
||||||
|
|
||||||
# Find any matching security
|
|
||||||
security = Security.find_or_create_by!(
|
|
||||||
ticker: plaid_security["ticker_symbol"]&.upcase,
|
|
||||||
exchange_operating_mic: operating_mic&.upcase
|
|
||||||
)
|
|
||||||
|
|
||||||
[ security, plaid_security ]
|
|
||||||
end
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private
|
|
||||||
def securities
|
|
||||||
@securities ||= plaid_account.raw_investments_payload["securities"] || []
|
|
||||||
end
|
|
||||||
|
|
||||||
# These are the tickers that Plaid considers a "security", but we do not (mostly cash-like tickers)
|
|
||||||
#
|
|
||||||
# For example, "CUR:USD" is what Plaid uses for the "Cash Holding" and represents brokerage cash sitting
|
|
||||||
# in the brokerage account. Internally, we treat brokerage cash as a separate concept. It is NOT a holding
|
|
||||||
# in the Maybe app (although in the UI, we show it next to other holdings).
|
|
||||||
def ignored_plaid_security_tickers
|
|
||||||
[ "CUR:USD" ]
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,22 +0,0 @@
|
||||||
# Resolves a Plaid security to an internal Security record, or nil
|
|
||||||
class PlaidAccount::SecurityProcessor
|
|
||||||
def initialize(plaid_security_id, plaid_securities)
|
|
||||||
@plaid_security_id = plaid_security_id
|
|
||||||
@plaid_securities = plaid_securities
|
|
||||||
end
|
|
||||||
|
|
||||||
def process
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
attr_reader :plaid_security_id, :plaid_securities
|
|
||||||
|
|
||||||
# Tries to find security, or returns the "proxy security" (common with options contracts that have underlying securities)
|
|
||||||
def plaid_security
|
|
||||||
security = securities.find { |s| s["security_id"] == plaid_security_id && s["ticker_symbol"].present? }
|
|
||||||
|
|
||||||
return security if security.present?
|
|
||||||
|
|
||||||
securities.find { |s| s["proxy_security_id"] == plaid_security_id }
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,4 +1,4 @@
|
||||||
class PlaidAccount::TransactionsProcessor
|
class PlaidAccount::Transactions::Processor
|
||||||
def initialize(plaid_account)
|
def initialize(plaid_account)
|
||||||
@plaid_account = plaid_account
|
@plaid_account = plaid_account
|
||||||
end
|
end
|
77
app/models/plaid_account/type_mappable.rb
Normal file
77
app/models/plaid_account/type_mappable.rb
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
module PlaidAccount::TypeMappable
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
UnknownAccountTypeError = Class.new(StandardError)
|
||||||
|
|
||||||
|
def map_accountable(plaid_type)
|
||||||
|
accountable_class = TYPE_MAPPING.dig(
|
||||||
|
plaid_type.to_sym,
|
||||||
|
:accountable
|
||||||
|
)
|
||||||
|
|
||||||
|
unless accountable_class
|
||||||
|
raise UnknownAccountTypeError, "Unknown account type: #{plaid_type}"
|
||||||
|
end
|
||||||
|
|
||||||
|
accountable_class.new
|
||||||
|
end
|
||||||
|
|
||||||
|
def map_subtype(plaid_type, plaid_subtype)
|
||||||
|
TYPE_MAPPING.dig(
|
||||||
|
plaid_type.to_sym,
|
||||||
|
:subtype_mapping,
|
||||||
|
plaid_subtype
|
||||||
|
) || "other"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Plaid Account Types -> Accountable Types
|
||||||
|
# https://plaid.com/docs/api/accounts/#account-type-schema
|
||||||
|
TYPE_MAPPING = {
|
||||||
|
depository: {
|
||||||
|
accountable: Depository,
|
||||||
|
subtype_mapping: {
|
||||||
|
"checking" => "checking",
|
||||||
|
"savings" => "savings",
|
||||||
|
"hsa" => "hsa",
|
||||||
|
"cd" => "cd",
|
||||||
|
"money market" => "money_market"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
credit: {
|
||||||
|
accountable: CreditCard,
|
||||||
|
subtype_mapping: {
|
||||||
|
"credit card" => "credit_card"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
loan: {
|
||||||
|
accountable: Loan,
|
||||||
|
subtype_mapping: {
|
||||||
|
"mortgage" => "mortgage",
|
||||||
|
"student" => "student",
|
||||||
|
"auto" => "auto",
|
||||||
|
"business" => "business",
|
||||||
|
"home equity" => "home_equity",
|
||||||
|
"line of credit" => "line_of_credit"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
investment: {
|
||||||
|
accountable: Investment,
|
||||||
|
subtype_mapping: {
|
||||||
|
"brokerage" => "brokerage",
|
||||||
|
"pension" => "pension",
|
||||||
|
"retirement" => "retirement",
|
||||||
|
"401k" => "401k",
|
||||||
|
"roth 401k" => "roth_401k",
|
||||||
|
"529" => "529_plan",
|
||||||
|
"hsa" => "hsa",
|
||||||
|
"mutual fund" => "mutual_fund",
|
||||||
|
"roth" => "roth_ira",
|
||||||
|
"ira" => "ira"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
other: {
|
||||||
|
accountable: OtherAsset,
|
||||||
|
subtype_mapping: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
Loading…
Add table
Add a link
Reference in a new issue