mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-08 15:05:22 +02:00
Plaid product processors
This commit is contained in:
parent
4069dc0ec6
commit
3bd7baf2f5
13 changed files with 412 additions and 435 deletions
|
@ -27,9 +27,14 @@ module Enrichable
|
|||
enrich_attributes({ attr => value }, source:, metadata:)
|
||||
end
|
||||
|
||||
# Enriches all attributes that haven't been locked yet
|
||||
# Enriches and logs all attributes that:
|
||||
# - Are not locked
|
||||
# - Are not ignored
|
||||
# - Have changed value from the last saved value
|
||||
def enrich_attributes(attrs, source:, metadata: {})
|
||||
enrichable_attrs = Array(attrs).reject { |k, _v| locked?(k) }
|
||||
enrichable_attrs = Array(attrs).reject do |attr_key, attr_value|
|
||||
locked?(attr_key) || ignored_enrichable_attributes.include?(attr_key) || self[attr_key.to_s] == attr_value
|
||||
end
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
enrichable_attrs.each do |attr, value|
|
||||
|
|
|
@ -1,57 +1,8 @@
|
|||
class PlaidAccount < ApplicationRecord
|
||||
TYPE_MAPPING = {
|
||||
"depository" => Depository,
|
||||
"credit" => CreditCard,
|
||||
"loan" => Loan,
|
||||
"investment" => Investment,
|
||||
"other" => OtherAsset
|
||||
}
|
||||
|
||||
belongs_to :plaid_item
|
||||
|
||||
has_one :account, dependent: :destroy
|
||||
|
||||
accepts_nested_attributes_for :account
|
||||
|
||||
class << self
|
||||
def find_or_create_from_plaid_data!(plaid_data, family)
|
||||
PlaidAccount.transaction do
|
||||
plaid_account = find_or_create_by!(plaid_id: plaid_data.account_id)
|
||||
|
||||
internal_account = family.accounts.find_or_initialize_by(plaid_account_id: plaid_account.id)
|
||||
|
||||
# Only set the name for new records or if the name is not locked
|
||||
if internal_account.new_record? || internal_account.enrichable?(:name)
|
||||
internal_account.name = plaid_data.name
|
||||
end
|
||||
internal_account.balance = plaid_data.balances.current || plaid_data.balances.available
|
||||
internal_account.currency = plaid_data.balances.iso_currency_code
|
||||
internal_account.accountable = TYPE_MAPPING[plaid_data.type].new
|
||||
|
||||
internal_account.save!
|
||||
plaid_account.save!
|
||||
|
||||
plaid_account
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def sync_account_data!(plaid_account_data)
|
||||
update!(
|
||||
current_balance: plaid_account_data.balances.current,
|
||||
available_balance: plaid_account_data.balances.available,
|
||||
currency: plaid_account_data.balances.iso_currency_code,
|
||||
plaid_type: plaid_account_data.type,
|
||||
plaid_subtype: plaid_account_data.subtype,
|
||||
account_attributes: {
|
||||
id: account.id,
|
||||
# Plaid guarantees at least 1 of these
|
||||
balance: plaid_account_data.balances.current || plaid_account_data.balances.available,
|
||||
cash_balance: derive_plaid_cash_balance(plaid_account_data.balances)
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def upsert_plaid_snapshot!(account_snapshot)
|
||||
assign_attributes(
|
||||
current_balance: account_snapshot.balances.current,
|
||||
|
@ -90,117 +41,4 @@ class PlaidAccount < ApplicationRecord
|
|||
|
||||
save!
|
||||
end
|
||||
|
||||
def sync_investments!(transactions:, holdings:, securities:)
|
||||
PlaidInvestmentSync.new(self).sync!(transactions:, holdings:, securities:)
|
||||
end
|
||||
|
||||
def sync_credit_data!(plaid_credit_data)
|
||||
account.update!(
|
||||
accountable_attributes: {
|
||||
id: account.accountable_id,
|
||||
minimum_payment: plaid_credit_data.minimum_payment_amount,
|
||||
apr: plaid_credit_data.aprs.first&.apr_percentage
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def sync_mortgage_data!(plaid_mortgage_data)
|
||||
create_initial_loan_balance(plaid_mortgage_data)
|
||||
|
||||
account.update!(
|
||||
accountable_attributes: {
|
||||
id: account.accountable_id,
|
||||
rate_type: plaid_mortgage_data.interest_rate&.type,
|
||||
interest_rate: plaid_mortgage_data.interest_rate&.percentage
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def sync_student_loan_data!(plaid_student_loan_data)
|
||||
create_initial_loan_balance(plaid_student_loan_data)
|
||||
|
||||
account.update!(
|
||||
accountable_attributes: {
|
||||
id: account.accountable_id,
|
||||
rate_type: "fixed",
|
||||
interest_rate: plaid_student_loan_data.interest_rate_percentage
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def sync_transactions!(added:, modified:, removed:)
|
||||
added.each do |plaid_txn|
|
||||
account.entries.find_or_create_by!(plaid_id: plaid_txn.transaction_id) do |t|
|
||||
t.name = plaid_txn.merchant_name || plaid_txn.original_description
|
||||
t.amount = plaid_txn.amount
|
||||
t.currency = plaid_txn.iso_currency_code
|
||||
t.date = plaid_txn.date
|
||||
t.entryable = Transaction.new(
|
||||
plaid_category: plaid_txn.personal_finance_category.primary,
|
||||
plaid_category_detailed: plaid_txn.personal_finance_category.detailed,
|
||||
merchant: find_or_create_merchant(plaid_txn)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
modified.each do |plaid_txn|
|
||||
existing_txn = account.entries.find_by(plaid_id: plaid_txn.transaction_id)
|
||||
|
||||
existing_txn.update!(
|
||||
amount: plaid_txn.amount,
|
||||
date: plaid_txn.date,
|
||||
entryable_attributes: {
|
||||
plaid_category: plaid_txn.personal_finance_category.primary,
|
||||
plaid_category_detailed: plaid_txn.personal_finance_category.detailed,
|
||||
merchant: find_or_create_merchant(plaid_txn)
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
removed.each do |plaid_txn|
|
||||
account.entries.find_by(plaid_id: plaid_txn.transaction_id)&.destroy
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def family
|
||||
plaid_item.family
|
||||
end
|
||||
|
||||
def create_initial_loan_balance(loan_data)
|
||||
if loan_data.origination_principal_amount.present? && loan_data.origination_date.present?
|
||||
account.entries.find_or_create_by!(plaid_id: loan_data.account_id) do |e|
|
||||
e.name = "Initial Principal"
|
||||
e.amount = loan_data.origination_principal_amount
|
||||
e.currency = account.currency
|
||||
e.date = loan_data.origination_date
|
||||
e.entryable = Valuation.new
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def find_or_create_merchant(plaid_txn)
|
||||
unless plaid_txn.merchant_entity_id.present? && plaid_txn.merchant_name.present?
|
||||
return nil
|
||||
end
|
||||
|
||||
ProviderMerchant.find_or_create_by!(
|
||||
source: "plaid",
|
||||
name: plaid_txn.merchant_name,
|
||||
) do |m|
|
||||
m.provider_merchant_id = plaid_txn.merchant_entity_id
|
||||
m.website_url = plaid_txn.website
|
||||
m.logo_url = plaid_txn.logo_url
|
||||
end
|
||||
end
|
||||
|
||||
def derive_plaid_cash_balance(plaid_balances)
|
||||
if account.investment?
|
||||
plaid_balances.available || 0
|
||||
else
|
||||
# For now, we will not distinguish between "cash" and "overall" balance for non-investment accounts
|
||||
plaid_balances.current || plaid_balances.available
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -11,7 +11,59 @@ class PlaidAccount::InvestmentBalanceProcessor
|
|||
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 || 0
|
||||
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
|
||||
|
|
131
app/models/plaid_account/investments_processor.rb
Normal file
131
app/models/plaid_account/investments_processor.rb
Normal file
|
@ -0,0 +1,131 @@
|
|||
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
|
55
app/models/plaid_account/liabilities_processor.rb
Normal file
55
app/models/plaid_account/liabilities_processor.rb
Normal file
|
@ -0,0 +1,55 @@
|
|||
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
|
|
@ -41,6 +41,7 @@ class PlaidAccount::Processor
|
|||
|
||||
PlaidAccount::TransactionsProcessor.new(plaid_account).process
|
||||
PlaidAccount::InvestmentsProcessor.new(plaid_account).process
|
||||
PlaidAccount::LiabilitiesProcessor.new(plaid_account).process
|
||||
end
|
||||
|
||||
private
|
||||
|
|
40
app/models/plaid_account/transactions_processor.rb
Normal file
40
app/models/plaid_account/transactions_processor.rb
Normal file
|
@ -0,0 +1,40 @@
|
|||
class PlaidAccount::TransactionsProcessor
|
||||
def initialize(plaid_account)
|
||||
@plaid_account = plaid_account
|
||||
end
|
||||
|
||||
def process
|
||||
PlaidAccount.transaction do
|
||||
modified_transactions.each do |transaction|
|
||||
PlaidEntry::TransactionProcessor.new(transaction, plaid_account: plaid_account).process
|
||||
end
|
||||
|
||||
removed_transactions.each do |transaction|
|
||||
remove_plaid_transaction(transaction)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :plaid_account
|
||||
|
||||
def account
|
||||
plaid_account.account
|
||||
end
|
||||
|
||||
def remove_plaid_transaction(raw_transaction)
|
||||
account.entries.find_by(plaid_id: raw_transaction["transaction_id"])&.destroy
|
||||
end
|
||||
|
||||
# Since we find_or_create_by transactions, we don't need a distinction between added/modified
|
||||
def modified_transactions
|
||||
modified = plaid_account.raw_transactions_payload["modified"] || []
|
||||
added = plaid_account.raw_transactions_payload["added"] || []
|
||||
|
||||
modified + added
|
||||
end
|
||||
|
||||
def removed_transactions
|
||||
plaid_account.raw_transactions_payload["removed"] || []
|
||||
end
|
||||
end
|
91
app/models/plaid_entry/transaction_processor.rb
Normal file
91
app/models/plaid_entry/transaction_processor.rb
Normal file
|
@ -0,0 +1,91 @@
|
|||
class PlaidEntry::TransactionProcessor
|
||||
# plaid_transaction is the raw hash fetched from Plaid API and converted to JSONB
|
||||
def initialize(plaid_transaction, plaid_account:)
|
||||
@plaid_transaction = plaid_transaction
|
||||
@plaid_account = plaid_account
|
||||
end
|
||||
|
||||
def process
|
||||
entry = account.entries.find_or_initialize_by(plaid_id: plaid_id) do |e|
|
||||
e.entryable = Transaction.new
|
||||
end
|
||||
|
||||
entry.enrich_attribute(
|
||||
:name,
|
||||
name,
|
||||
source: "plaid"
|
||||
)
|
||||
|
||||
entry.assign_attributes(
|
||||
amount: amount,
|
||||
currency: currency,
|
||||
date: date
|
||||
)
|
||||
|
||||
if merchant
|
||||
entry.transaction.enrich_attribute(
|
||||
:merchant_id,
|
||||
merchant.id,
|
||||
source: "plaid"
|
||||
)
|
||||
end
|
||||
|
||||
entry.transaction.assign_attributes(
|
||||
plaid_category: primary_category,
|
||||
plaid_category_detailed: detailed_category,
|
||||
)
|
||||
|
||||
entry.save!
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :plaid_transaction, :plaid_account
|
||||
|
||||
def account
|
||||
plaid_account.account
|
||||
end
|
||||
|
||||
def plaid_id
|
||||
plaid_transaction["transaction_id"]
|
||||
end
|
||||
|
||||
def name
|
||||
plaid_transaction["merchant_name"] || plaid_transaction["original_description"]
|
||||
end
|
||||
|
||||
def amount
|
||||
plaid_transaction["amount"]
|
||||
end
|
||||
|
||||
def currency
|
||||
plaid_transaction["iso_currency_code"]
|
||||
end
|
||||
|
||||
def date
|
||||
plaid_transaction["date"]
|
||||
end
|
||||
|
||||
def primary_category
|
||||
plaid_transaction["personal_finance_category"]["primary"]
|
||||
end
|
||||
|
||||
def detailed_category
|
||||
plaid_transaction["personal_finance_category"]["detailed"]
|
||||
end
|
||||
|
||||
def merchant
|
||||
merchant_id = plaid_transaction["merchant_entity_id"]
|
||||
merchant_name = plaid_transaction["merchant_name"]
|
||||
|
||||
return nil unless merchant_id.present? && merchant_name.present?
|
||||
|
||||
ProviderMerchant.find_or_create_by!(
|
||||
source: "plaid",
|
||||
name: merchant_name,
|
||||
) do |m|
|
||||
m.provider_merchant_id = merchant_id
|
||||
m.website_url = plaid_transaction["website"]
|
||||
m.logo_url = plaid_transaction["logo_url"]
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,115 +0,0 @@
|
|||
class PlaidInvestmentSync
|
||||
attr_reader :plaid_account
|
||||
|
||||
def initialize(plaid_account)
|
||||
@plaid_account = plaid_account
|
||||
end
|
||||
|
||||
def sync!(transactions: [], holdings: [], securities: [])
|
||||
@transactions = transactions
|
||||
@holdings = holdings
|
||||
@securities = securities
|
||||
|
||||
PlaidAccount.transaction do
|
||||
normalize_cash_balance!
|
||||
sync_transactions!
|
||||
sync_holdings!
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :transactions, :holdings, :securities
|
||||
|
||||
# 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 normalize_cash_balance!
|
||||
excludable_cash_holdings = holdings.select do |h|
|
||||
internal_security, plaid_security = get_security(h.security_id, securities)
|
||||
internal_security.present? && (plaid_security&.is_cash_equivalent || plaid_security&.type == "cash")
|
||||
end
|
||||
|
||||
excludable_cash_holdings_value = excludable_cash_holdings.sum { |h| h.quantity * h.institution_price }
|
||||
|
||||
plaid_account.account.update!(
|
||||
cash_balance: plaid_account.account.cash_balance - excludable_cash_holdings_value
|
||||
)
|
||||
end
|
||||
|
||||
def sync_transactions!
|
||||
transactions.each do |transaction|
|
||||
security, plaid_security = get_security(transaction.security_id, securities)
|
||||
|
||||
next if security.nil? && plaid_security.nil?
|
||||
|
||||
if transaction.type == "cash" || plaid_security.ticker_symbol == "CUR:USD"
|
||||
new_transaction = plaid_account.account.entries.find_or_create_by!(plaid_id: transaction.investment_transaction_id) do |t|
|
||||
t.name = transaction.name
|
||||
t.amount = transaction.amount
|
||||
t.currency = transaction.iso_currency_code
|
||||
t.date = transaction.date
|
||||
t.entryable = Transaction.new
|
||||
end
|
||||
else
|
||||
new_transaction = plaid_account.account.entries.find_or_create_by!(plaid_id: transaction.investment_transaction_id) do |t|
|
||||
t.name = transaction.name
|
||||
t.amount = transaction.quantity * transaction.price
|
||||
t.currency = transaction.iso_currency_code
|
||||
t.date = transaction.date
|
||||
t.entryable = Trade.new(
|
||||
security: security,
|
||||
qty: transaction.quantity,
|
||||
price: transaction.price,
|
||||
currency: transaction.iso_currency_code
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def sync_holdings!
|
||||
# Update only the current day holdings. The account sync will populate historical values based on trades.
|
||||
holdings.each do |holding|
|
||||
internal_security, _plaid_security = get_security(holding.security_id, securities)
|
||||
|
||||
next if internal_security.nil?
|
||||
|
||||
existing_holding = plaid_account.account.holdings.find_or_initialize_by(
|
||||
security: internal_security,
|
||||
date: Date.current,
|
||||
currency: holding.iso_currency_code
|
||||
)
|
||||
|
||||
existing_holding.qty = holding.quantity
|
||||
existing_holding.price = holding.institution_price
|
||||
existing_holding.amount = holding.quantity * holding.institution_price
|
||||
existing_holding.save!
|
||||
end
|
||||
end
|
||||
|
||||
def get_security(plaid_security_id, securities)
|
||||
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
|
|
@ -60,6 +60,31 @@ class PlaidItem < ApplicationRecord
|
|||
.exists?
|
||||
end
|
||||
|
||||
def import_latest_plaid_data
|
||||
PlaidItem::Importer.new(self, plaid_provider: plaid_provider).import
|
||||
end
|
||||
|
||||
# Reads the fetched data and updates internal domain objects
|
||||
# Generally, this should only be called within a "sync", but can be called
|
||||
# manually to "re-sync" the already fetched data
|
||||
def process_accounts
|
||||
plaid_accounts.each do |plaid_account|
|
||||
PlaidAccount::Processor.new(plaid_account).process
|
||||
end
|
||||
end
|
||||
|
||||
# Once all the data is fetched, we can schedule account syncs to calculate historical balances
|
||||
def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil)
|
||||
accounts.each do |account|
|
||||
account.sync_later(
|
||||
parent_sync: parent_sync,
|
||||
window_start_date: window_start_date,
|
||||
window_end_date: window_end_date
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Saves the raw data fetched from Plaid API for this item
|
||||
def upsert_plaid_snapshot!(item_snapshot)
|
||||
assign_attributes(
|
||||
available_products: item_snapshot.available_products,
|
||||
|
@ -70,6 +95,7 @@ class PlaidItem < ApplicationRecord
|
|||
save!
|
||||
end
|
||||
|
||||
# Saves the raw data fetched from Plaid API for this item's institution
|
||||
def upsert_plaid_institution_snapshot!(institution_snapshot)
|
||||
assign_attributes(
|
||||
institution_id: institution_snapshot.institution_id,
|
||||
|
|
|
@ -7,36 +7,24 @@ class PlaidItem::Syncer
|
|||
|
||||
def perform_sync(sync)
|
||||
# Loads item metadata, accounts, transactions, and other data to our DB
|
||||
fetch_and_import_item_data
|
||||
plaid_item.import_latest_plaid_data
|
||||
|
||||
# Processes the raw Plaid data and updates internal domain objects
|
||||
plaid_item.plaid_accounts.each do |plaid_account|
|
||||
PlaidAccount::Processor.new(plaid_account).process
|
||||
end
|
||||
plaid_item.process_accounts
|
||||
|
||||
# All data is synced, so we can now run an account sync to calculate historical balances and more
|
||||
plaid_item.reload.accounts.each do |account|
|
||||
account.sync_later(
|
||||
plaid_item.schedule_account_syncs(
|
||||
parent_sync: sync,
|
||||
window_start_date: sync.window_start_date,
|
||||
window_end_date: sync.window_end_date
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def perform_post_sync
|
||||
plaid_item.auto_match_categories!
|
||||
end
|
||||
|
||||
private
|
||||
def plaid_provider
|
||||
plaid_item.plaid_provider
|
||||
end
|
||||
|
||||
def fetch_and_import_item_data
|
||||
PlaidItem::Importer.new(plaid_item, plaid_provider: plaid_provider).import
|
||||
end
|
||||
|
||||
def safe_fetch_plaid_data(method)
|
||||
begin
|
||||
plaid.send(method, plaid_item)
|
||||
|
@ -55,59 +43,6 @@ class PlaidItem::Syncer
|
|||
end
|
||||
|
||||
def fetch_and_load_plaid_data
|
||||
data = {}
|
||||
|
||||
# Log what we're about to fetch
|
||||
Rails.logger.info "Starting Plaid data fetch (accounts, transactions, investments, liabilities)"
|
||||
|
||||
item = plaid.get_item(plaid_item.access_token).item
|
||||
plaid_item.update!(available_products: item.available_products, billed_products: item.billed_products)
|
||||
|
||||
# Institution details
|
||||
if item.institution_id.present?
|
||||
begin
|
||||
Rails.logger.info "Fetching Plaid institution details for #{item.institution_id}"
|
||||
institution = plaid.get_institution(item.institution_id)
|
||||
plaid_item.update!(
|
||||
institution_id: item.institution_id,
|
||||
institution_url: institution.institution.url,
|
||||
institution_color: institution.institution.primary_color
|
||||
)
|
||||
rescue Plaid::ApiError => e
|
||||
Rails.logger.warn "Failed to fetch Plaid institution details: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
# Accounts
|
||||
fetched_accounts = plaid.get_item_accounts(plaid_item).accounts
|
||||
data[:accounts] = fetched_accounts || []
|
||||
Rails.logger.info "Processing Plaid accounts (count: #{fetched_accounts.size})"
|
||||
|
||||
internal_plaid_accounts = fetched_accounts.map do |account|
|
||||
internal_plaid_account = plaid_item.plaid_accounts.find_or_create_from_plaid_data!(account, plaid_item.family)
|
||||
internal_plaid_account.sync_account_data!(account)
|
||||
internal_plaid_account
|
||||
end
|
||||
|
||||
# Transactions
|
||||
fetched_transactions = safe_fetch_plaid_data(:get_item_transactions)
|
||||
data[:transactions] = fetched_transactions || []
|
||||
|
||||
if fetched_transactions
|
||||
Rails.logger.info "Processing Plaid transactions (added: #{fetched_transactions.added.size}, modified: #{fetched_transactions.modified.size}, removed: #{fetched_transactions.removed.size})"
|
||||
PlaidItem.transaction do
|
||||
internal_plaid_accounts.each do |internal_plaid_account|
|
||||
added = fetched_transactions.added.select { |t| t.account_id == internal_plaid_account.plaid_id }
|
||||
modified = fetched_transactions.modified.select { |t| t.account_id == internal_plaid_account.plaid_id }
|
||||
removed = fetched_transactions.removed.select { |t| t.account_id == internal_plaid_account.plaid_id }
|
||||
|
||||
internal_plaid_account.sync_transactions!(added:, modified:, removed:)
|
||||
end
|
||||
|
||||
plaid_item.update!(next_cursor: fetched_transactions.cursor)
|
||||
end
|
||||
end
|
||||
|
||||
# Investments
|
||||
fetched_investments = safe_fetch_plaid_data(:get_item_investments)
|
||||
data[:investments] = fetched_investments || []
|
||||
|
|
|
@ -66,7 +66,7 @@
|
|||
<%= f.fields_for :entryable do |ef| %>
|
||||
|
||||
<%= ef.collection_select :merchant_id,
|
||||
Current.family.merchants.alphabetically,
|
||||
[@entry.transaction.merchant, *Current.family.merchants.alphabetically].compact,
|
||||
:id, :name,
|
||||
{ include_blank: t(".none"),
|
||||
label: t(".merchant_label"),
|
||||
|
|
|
@ -1,82 +0,0 @@
|
|||
require "test_helper"
|
||||
|
||||
class PlaidInvestmentSyncTest < ActiveSupport::TestCase
|
||||
include PlaidTestHelper
|
||||
|
||||
setup do
|
||||
@plaid_account = plaid_accounts(:one)
|
||||
end
|
||||
|
||||
test "syncs basic investments and handles cash holding" do
|
||||
assert_equal 0, @plaid_account.account.entries.count
|
||||
assert_equal 0, @plaid_account.account.holdings.count
|
||||
|
||||
plaid_aapl_id = "aapl_id"
|
||||
|
||||
transactions = [
|
||||
create_plaid_investment_transaction({
|
||||
investment_transaction_id: "inv_txn_1",
|
||||
security_id: plaid_aapl_id,
|
||||
quantity: 10,
|
||||
price: 200,
|
||||
date: 5.days.ago.to_date,
|
||||
type: "buy"
|
||||
})
|
||||
]
|
||||
|
||||
holdings = [
|
||||
create_plaid_cash_holding,
|
||||
create_plaid_holding({
|
||||
security_id: plaid_aapl_id,
|
||||
quantity: 10,
|
||||
institution_price: 200,
|
||||
cost_basis: 2000
|
||||
})
|
||||
]
|
||||
|
||||
securities = [
|
||||
create_plaid_security({
|
||||
security_id: plaid_aapl_id,
|
||||
close_price: 200,
|
||||
ticker_symbol: "AAPL"
|
||||
})
|
||||
]
|
||||
|
||||
# Cash holding should be ignored, resulting in 1, NOT 2 total holdings after sync
|
||||
assert_difference -> { Trade.count } => 1,
|
||||
-> { Transaction.count } => 0,
|
||||
-> { Holding.count } => 1,
|
||||
-> { Security.count } => 0 do
|
||||
PlaidInvestmentSync.new(@plaid_account).sync!(
|
||||
transactions: transactions,
|
||||
holdings: holdings,
|
||||
securities: securities
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Some cash transactions from Plaid are labeled as type: "cash" while others are linked to a "cash" security
|
||||
# In both cases, we should treat them as cash-only transactions (not trades)
|
||||
test "handles cash investment transactions" do
|
||||
transactions = [
|
||||
create_plaid_investment_transaction({
|
||||
price: 1,
|
||||
quantity: 5,
|
||||
amount: 5,
|
||||
type: "fee",
|
||||
subtype: "miscellaneous fee",
|
||||
security_id: PLAID_TEST_CASH_SECURITY_ID
|
||||
})
|
||||
]
|
||||
|
||||
assert_difference -> { Trade.count } => 0,
|
||||
-> { Transaction.count } => 1,
|
||||
-> { Security.count } => 0 do
|
||||
PlaidInvestmentSync.new(@plaid_account).sync!(
|
||||
transactions: transactions,
|
||||
holdings: [ create_plaid_cash_holding ],
|
||||
securities: [ create_plaid_cash_security ]
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Add table
Add a link
Reference in a new issue