From df5f926a0ea475b891e8432e156da6191e40cdd6 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 22 May 2025 22:58:57 -0400 Subject: [PATCH] complete plaid processors first pass --- app/models/credit_card.rb | 4 + app/models/depository.rb | 5 +- app/models/investment.rb | 3 +- app/models/loan.rb | 7 + .../credit_liability_processor.rb | 16 --- .../investment_balance_processor.rb | 71 ---------- .../investment_holdings_processor.rb | 18 --- .../investment_transactions_processor.rb | 17 --- .../investments/balance_processor.rb | 36 +++++ .../investments/holdings_processor.rb | 41 ++++++ .../investments/security_resolver.rb | 82 +++++++++++ .../investments/transactions_processor.rb | 78 +++++++++++ .../plaid_account/investments_processor.rb | 131 ------------------ .../liabilities/credit_processor.rb | 25 ++++ .../liabilities/mortgage_processor.rb | 25 ++++ .../liabilities/student_loan_processor.rb | 41 ++++++ .../plaid_account/liabilities_processor.rb | 55 -------- app/models/plaid_account/processor.rb | 109 +++++++++------ app/models/plaid_account/securitizable.rb | 39 ------ .../plaid_account/security_processor.rb | 22 --- .../processor.rb} | 2 +- app/models/plaid_account/type_mappable.rb | 77 ++++++++++ 22 files changed, 490 insertions(+), 414 deletions(-) delete mode 100644 app/models/plaid_account/credit_liability_processor.rb delete mode 100644 app/models/plaid_account/investment_balance_processor.rb delete mode 100644 app/models/plaid_account/investment_holdings_processor.rb delete mode 100644 app/models/plaid_account/investment_transactions_processor.rb create mode 100644 app/models/plaid_account/investments/balance_processor.rb create mode 100644 app/models/plaid_account/investments/holdings_processor.rb create mode 100644 app/models/plaid_account/investments/security_resolver.rb create mode 100644 app/models/plaid_account/investments/transactions_processor.rb delete mode 100644 app/models/plaid_account/investments_processor.rb create mode 100644 app/models/plaid_account/liabilities/credit_processor.rb create mode 100644 app/models/plaid_account/liabilities/mortgage_processor.rb create mode 100644 app/models/plaid_account/liabilities/student_loan_processor.rb delete mode 100644 app/models/plaid_account/liabilities_processor.rb delete mode 100644 app/models/plaid_account/securitizable.rb delete mode 100644 app/models/plaid_account/security_processor.rb rename app/models/plaid_account/{transactions_processor.rb => transactions/processor.rb} (95%) create mode 100644 app/models/plaid_account/type_mappable.rb diff --git a/app/models/credit_card.rb b/app/models/credit_card.rb index fa621546..05bf7746 100644 --- a/app/models/credit_card.rb +++ b/app/models/credit_card.rb @@ -1,6 +1,10 @@ class CreditCard < ApplicationRecord include Accountable + SUBTYPES = { + "credit_card" => { short: "Credit Card", long: "Credit Card" } + }.freeze + class << self def color "#F13636" diff --git a/app/models/depository.rb b/app/models/depository.rb index 577a061c..b788a6d4 100644 --- a/app/models/depository.rb +++ b/app/models/depository.rb @@ -3,7 +3,10 @@ class Depository < ApplicationRecord SUBTYPES = { "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 class << self diff --git a/app/models/investment.rb b/app/models/investment.rb index 6b2518ce..4e4c25c8 100644 --- a/app/models/investment.rb +++ b/app/models/investment.rb @@ -6,12 +6,11 @@ class Investment < ApplicationRecord "pension" => { short: "Pension", long: "Pension" }, "retirement" => { short: "Retirement", long: "Retirement" }, "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)" }, "529_plan" => { short: "529 Plan", long: "529 Plan" }, "hsa" => { short: "HSA", long: "Health Savings Account" }, "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" }, "angel" => { short: "Angel", long: "Angel" } }.freeze diff --git a/app/models/loan.rb b/app/models/loan.rb index 283e112e..5a206e7a 100644 --- a/app/models/loan.rb +++ b/app/models/loan.rb @@ -1,6 +1,13 @@ class Loan < ApplicationRecord 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 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? diff --git a/app/models/plaid_account/credit_liability_processor.rb b/app/models/plaid_account/credit_liability_processor.rb deleted file mode 100644 index 6d1ce099..00000000 --- a/app/models/plaid_account/credit_liability_processor.rb +++ /dev/null @@ -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 diff --git a/app/models/plaid_account/investment_balance_processor.rb b/app/models/plaid_account/investment_balance_processor.rb deleted file mode 100644 index ed25a8a8..00000000 --- a/app/models/plaid_account/investment_balance_processor.rb +++ /dev/null @@ -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 diff --git a/app/models/plaid_account/investment_holdings_processor.rb b/app/models/plaid_account/investment_holdings_processor.rb deleted file mode 100644 index 7282a3e7..00000000 --- a/app/models/plaid_account/investment_holdings_processor.rb +++ /dev/null @@ -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 diff --git a/app/models/plaid_account/investment_transactions_processor.rb b/app/models/plaid_account/investment_transactions_processor.rb deleted file mode 100644 index c713924f..00000000 --- a/app/models/plaid_account/investment_transactions_processor.rb +++ /dev/null @@ -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 diff --git a/app/models/plaid_account/investments/balance_processor.rb b/app/models/plaid_account/investments/balance_processor.rb new file mode 100644 index 00000000..712c2c22 --- /dev/null +++ b/app/models/plaid_account/investments/balance_processor.rb @@ -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 diff --git a/app/models/plaid_account/investments/holdings_processor.rb b/app/models/plaid_account/investments/holdings_processor.rb new file mode 100644 index 00000000..8becfa90 --- /dev/null +++ b/app/models/plaid_account/investments/holdings_processor.rb @@ -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 diff --git a/app/models/plaid_account/investments/security_resolver.rb b/app/models/plaid_account/investments/security_resolver.rb new file mode 100644 index 00000000..3d544036 --- /dev/null +++ b/app/models/plaid_account/investments/security_resolver.rb @@ -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 diff --git a/app/models/plaid_account/investments/transactions_processor.rb b/app/models/plaid_account/investments/transactions_processor.rb new file mode 100644 index 00000000..b15bc9f0 --- /dev/null +++ b/app/models/plaid_account/investments/transactions_processor.rb @@ -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 diff --git a/app/models/plaid_account/investments_processor.rb b/app/models/plaid_account/investments_processor.rb deleted file mode 100644 index b46a7d7e..00000000 --- a/app/models/plaid_account/investments_processor.rb +++ /dev/null @@ -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 diff --git a/app/models/plaid_account/liabilities/credit_processor.rb b/app/models/plaid_account/liabilities/credit_processor.rb new file mode 100644 index 00000000..cc487295 --- /dev/null +++ b/app/models/plaid_account/liabilities/credit_processor.rb @@ -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 diff --git a/app/models/plaid_account/liabilities/mortgage_processor.rb b/app/models/plaid_account/liabilities/mortgage_processor.rb new file mode 100644 index 00000000..d4610362 --- /dev/null +++ b/app/models/plaid_account/liabilities/mortgage_processor.rb @@ -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 diff --git a/app/models/plaid_account/liabilities/student_loan_processor.rb b/app/models/plaid_account/liabilities/student_loan_processor.rb new file mode 100644 index 00000000..09970c3a --- /dev/null +++ b/app/models/plaid_account/liabilities/student_loan_processor.rb @@ -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 diff --git a/app/models/plaid_account/liabilities_processor.rb b/app/models/plaid_account/liabilities_processor.rb deleted file mode 100644 index 60248260..00000000 --- a/app/models/plaid_account/liabilities_processor.rb +++ /dev/null @@ -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 diff --git a/app/models/plaid_account/processor.rb b/app/models/plaid_account/processor.rb index 21e7e0ed..1e81888d 100644 --- a/app/models/plaid_account/processor.rb +++ b/app/models/plaid_account/processor.rb @@ -1,47 +1,21 @@ class PlaidAccount::Processor + include PlaidAccount::TypeMappable + 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) @plaid_account = plaid_account 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 - PlaidAccount.transaction do - account = family.accounts.find_or_initialize_by( - plaid_account_id: plaid_account.id - ) - - # Name is the only attribute a user can override for Plaid accounts - account.enrich_attribute( - :name, - plaid_account.name, - source: "plaid" - ) - - account.assign_attributes( - accountable: accountable, - balance: balance, - currency: plaid_account.currency, - cash_balance: cash_balance - ) - - account.save! - end - - PlaidAccount::TransactionsProcessor.new(plaid_account).process - PlaidAccount::InvestmentsProcessor.new(plaid_account).process - PlaidAccount::LiabilitiesProcessor.new(plaid_account).process + process_account! + process_transactions + process_investments + process_liabilities end private @@ -49,12 +23,59 @@ class PlaidAccount::Processor plaid_account.plaid_item.family end - def accountable - accountable_class = TYPE_MAPPING[plaid_account.plaid_type] + # Shared securities reader and resolver + def security_resolver + @security_resolver ||= PlaidAccount::Investments::SecurityResolver.new(plaid_account) + end - raise UnknownAccountTypeError, "Unknown account type: #{plaid_account.plaid_type}" unless accountable_class + def process_account! + PlaidAccount.transaction do + account = family.accounts.find_or_initialize_by( + plaid_account_id: plaid_account.id + ) - accountable_class.new + # Name is the only attribute a user can override for Plaid accounts + account.enrich_attribute( + :name, + plaid_account.name, + source: "plaid" + ) + + account.assign_attributes( + accountable: map_accountable(plaid_account.plaid_type), + subtype: map_subtype(plaid_account.plaid_type, plaid_account.plaid_subtype), + balance: balance, + currency: plaid_account.currency, + cash_balance: cash_balance + ) + + account.save! + end + end + + def process_transactions + PlaidAccount::Transactions::Processor.new(plaid_account).process + rescue => e + report_exception(e) + end + + def process_investments + 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 + + def process_liabilities + case [ plaid_account.plaid_type, plaid_account.plaid_subtype ] + 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 def balance @@ -76,6 +97,12 @@ class PlaidAccount::Processor end 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 diff --git a/app/models/plaid_account/securitizable.rb b/app/models/plaid_account/securitizable.rb deleted file mode 100644 index 757a1aaa..00000000 --- a/app/models/plaid_account/securitizable.rb +++ /dev/null @@ -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 diff --git a/app/models/plaid_account/security_processor.rb b/app/models/plaid_account/security_processor.rb deleted file mode 100644 index 428de1c1..00000000 --- a/app/models/plaid_account/security_processor.rb +++ /dev/null @@ -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 diff --git a/app/models/plaid_account/transactions_processor.rb b/app/models/plaid_account/transactions/processor.rb similarity index 95% rename from app/models/plaid_account/transactions_processor.rb rename to app/models/plaid_account/transactions/processor.rb index 89fa1f3e..92b5214f 100644 --- a/app/models/plaid_account/transactions_processor.rb +++ b/app/models/plaid_account/transactions/processor.rb @@ -1,4 +1,4 @@ -class PlaidAccount::TransactionsProcessor +class PlaidAccount::Transactions::Processor def initialize(plaid_account) @plaid_account = plaid_account end diff --git a/app/models/plaid_account/type_mappable.rb b/app/models/plaid_account/type_mappable.rb new file mode 100644 index 00000000..e91b66d8 --- /dev/null +++ b/app/models/plaid_account/type_mappable.rb @@ -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