mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-09 07:25:19 +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:)
|
enrich_attributes({ attr => value }, source:, metadata:)
|
||||||
end
|
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: {})
|
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
|
ActiveRecord::Base.transaction do
|
||||||
enrichable_attrs.each do |attr, value|
|
enrichable_attrs.each do |attr, value|
|
||||||
|
|
|
@ -1,57 +1,8 @@
|
||||||
class PlaidAccount < ApplicationRecord
|
class PlaidAccount < ApplicationRecord
|
||||||
TYPE_MAPPING = {
|
|
||||||
"depository" => Depository,
|
|
||||||
"credit" => CreditCard,
|
|
||||||
"loan" => Loan,
|
|
||||||
"investment" => Investment,
|
|
||||||
"other" => OtherAsset
|
|
||||||
}
|
|
||||||
|
|
||||||
belongs_to :plaid_item
|
belongs_to :plaid_item
|
||||||
|
|
||||||
has_one :account, dependent: :destroy
|
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)
|
def upsert_plaid_snapshot!(account_snapshot)
|
||||||
assign_attributes(
|
assign_attributes(
|
||||||
current_balance: account_snapshot.balances.current,
|
current_balance: account_snapshot.balances.current,
|
||||||
|
@ -90,117 +41,4 @@ class PlaidAccount < ApplicationRecord
|
||||||
|
|
||||||
save!
|
save!
|
||||||
end
|
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
|
end
|
||||||
|
|
|
@ -11,7 +11,59 @@ class PlaidAccount::InvestmentBalanceProcessor
|
||||||
plaid_account.current_balance || plaid_account.available_balance
|
plaid_account.current_balance || plaid_account.available_balance
|
||||||
end
|
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
|
def cash_balance
|
||||||
plaid_account.available_balance || 0
|
plaid_account.available_balance - excludable_cash_holdings_value
|
||||||
end
|
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
|
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::TransactionsProcessor.new(plaid_account).process
|
||||||
PlaidAccount::InvestmentsProcessor.new(plaid_account).process
|
PlaidAccount::InvestmentsProcessor.new(plaid_account).process
|
||||||
|
PlaidAccount::LiabilitiesProcessor.new(plaid_account).process
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
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?
|
.exists?
|
||||||
end
|
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)
|
def upsert_plaid_snapshot!(item_snapshot)
|
||||||
assign_attributes(
|
assign_attributes(
|
||||||
available_products: item_snapshot.available_products,
|
available_products: item_snapshot.available_products,
|
||||||
|
@ -70,6 +95,7 @@ class PlaidItem < ApplicationRecord
|
||||||
save!
|
save!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Saves the raw data fetched from Plaid API for this item's institution
|
||||||
def upsert_plaid_institution_snapshot!(institution_snapshot)
|
def upsert_plaid_institution_snapshot!(institution_snapshot)
|
||||||
assign_attributes(
|
assign_attributes(
|
||||||
institution_id: institution_snapshot.institution_id,
|
institution_id: institution_snapshot.institution_id,
|
||||||
|
|
|
@ -7,21 +7,17 @@ class PlaidItem::Syncer
|
||||||
|
|
||||||
def perform_sync(sync)
|
def perform_sync(sync)
|
||||||
# Loads item metadata, accounts, transactions, and other data to our DB
|
# 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
|
# Processes the raw Plaid data and updates internal domain objects
|
||||||
plaid_item.plaid_accounts.each do |plaid_account|
|
plaid_item.process_accounts
|
||||||
PlaidAccount::Processor.new(plaid_account).process
|
|
||||||
end
|
|
||||||
|
|
||||||
# All data is synced, so we can now run an account sync to calculate historical balances and more
|
# 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|
|
plaid_item.schedule_account_syncs(
|
||||||
account.sync_later(
|
parent_sync: sync,
|
||||||
parent_sync: sync,
|
window_start_date: sync.window_start_date,
|
||||||
window_start_date: sync.window_start_date,
|
window_end_date: sync.window_end_date
|
||||||
window_end_date: sync.window_end_date
|
)
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def perform_post_sync
|
def perform_post_sync
|
||||||
|
@ -29,14 +25,6 @@ class PlaidItem::Syncer
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
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)
|
def safe_fetch_plaid_data(method)
|
||||||
begin
|
begin
|
||||||
plaid.send(method, plaid_item)
|
plaid.send(method, plaid_item)
|
||||||
|
@ -55,59 +43,6 @@ class PlaidItem::Syncer
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch_and_load_plaid_data
|
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
|
# Investments
|
||||||
fetched_investments = safe_fetch_plaid_data(:get_item_investments)
|
fetched_investments = safe_fetch_plaid_data(:get_item_investments)
|
||||||
data[:investments] = fetched_investments || []
|
data[:investments] = fetched_investments || []
|
||||||
|
|
|
@ -66,7 +66,7 @@
|
||||||
<%= f.fields_for :entryable do |ef| %>
|
<%= f.fields_for :entryable do |ef| %>
|
||||||
|
|
||||||
<%= ef.collection_select :merchant_id,
|
<%= ef.collection_select :merchant_id,
|
||||||
Current.family.merchants.alphabetically,
|
[@entry.transaction.merchant, *Current.family.merchants.alphabetically].compact,
|
||||||
:id, :name,
|
:id, :name,
|
||||||
{ include_blank: t(".none"),
|
{ include_blank: t(".none"),
|
||||||
label: t(".merchant_label"),
|
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