mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-22 14:49:38 +02:00
* Basic plaid data model and linking * Remove institutions, add plaid items * Improve schema and Plaid provider * Add webhook verification sketch * Webhook verification * Item accounts and balances sync setup * Provide test encryption keys * Fix test * Only provide encryption keys in prod * Try defining keys in test env * Consolidate account sync logic * Add back plaid account initialization * Plaid transaction sync * Sync UI overhaul for Plaid * Add liability and investment syncing * Handle investment webhooks and process current day holdings * Remove logs * Remove "all" period select for performance * fix amount calc * Remove todo comment * Coming soon for investment historical data * Document Plaid configuration * Listen for holding updates
208 lines
6.6 KiB
Ruby
208 lines
6.6 KiB
Ruby
class PlaidAccount < ApplicationRecord
|
|
include Plaidable
|
|
|
|
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)
|
|
find_or_create_by!(plaid_id: plaid_data.account_id) do |a|
|
|
a.account = family.accounts.new(
|
|
name: plaid_data.name,
|
|
balance: plaid_data.balances.current,
|
|
currency: plaid_data.balances.iso_currency_code,
|
|
accountable: TYPE_MAPPING[plaid_data.type].new
|
|
)
|
|
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,
|
|
balance: plaid_account_data.balances.current
|
|
}
|
|
)
|
|
end
|
|
|
|
def sync_investments!(transactions:, holdings:, securities:)
|
|
transactions.each do |transaction|
|
|
if transaction.type == "cash"
|
|
new_transaction = 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.marked_as_transfer = transaction.subtype.in?(%w[deposit withdrawal])
|
|
t.entryable = Account::Transaction.new
|
|
end
|
|
else
|
|
security = get_security(transaction.security, securities)
|
|
next if security.nil?
|
|
new_transaction = 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 = Account::Trade.new(
|
|
security: security,
|
|
qty: transaction.quantity,
|
|
price: transaction.price,
|
|
currency: transaction.iso_currency_code
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
# Update only the current day holdings. The account sync will populate historical values based on trades.
|
|
holdings.each do |holding|
|
|
internal_security = get_security(holding.security, securities)
|
|
next if internal_security.nil?
|
|
|
|
existing_holding = 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 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.name
|
|
t.amount = plaid_txn.amount
|
|
t.currency = plaid_txn.iso_currency_code
|
|
t.date = plaid_txn.date
|
|
t.marked_as_transfer = transfer?(plaid_txn)
|
|
t.entryable = Account::Transaction.new(
|
|
category: get_category(plaid_txn.personal_finance_category.primary),
|
|
merchant: get_merchant(plaid_txn.merchant_name)
|
|
)
|
|
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
|
|
)
|
|
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 get_security(plaid_security, securities)
|
|
security = nil
|
|
|
|
if plaid_security.ticker_symbol.present?
|
|
security = plaid_security
|
|
else
|
|
security = securities.find { |s| s.security_id == plaid_security.proxy_security_id }
|
|
end
|
|
|
|
Security.find_or_create_by!(
|
|
ticker: security.ticker_symbol,
|
|
exchange_mic: security.market_identifier_code || "XNAS",
|
|
country_code: "US"
|
|
) if security.present?
|
|
end
|
|
|
|
def transfer?(plaid_txn)
|
|
transfer_categories = [ "TRANSFER_IN", "TRANSFER_OUT", "LOAN_PAYMENTS" ]
|
|
|
|
transfer_categories.include?(plaid_txn.personal_finance_category.primary)
|
|
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 = Account::Valuation.new
|
|
end
|
|
end
|
|
end
|
|
|
|
# See https://plaid.com/documents/transactions-personal-finance-category-taxonomy.csv
|
|
def get_category(plaid_category)
|
|
ignored_categories = [ "BANK_FEES", "TRANSFER_IN", "TRANSFER_OUT", "LOAN_PAYMENTS", "OTHER" ]
|
|
|
|
return nil if ignored_categories.include?(plaid_category)
|
|
|
|
family.categories.find_or_create_by!(name: plaid_category.titleize)
|
|
end
|
|
|
|
def get_merchant(plaid_merchant_name)
|
|
return nil if plaid_merchant_name.blank?
|
|
|
|
family.merchants.find_or_create_by!(name: plaid_merchant_name)
|
|
end
|
|
end
|