From 3bd7baf2f5daf71d5c474a74fcd120c58beaeab5 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Tue, 20 May 2025 19:03:37 -0400 Subject: [PATCH] Plaid product processors --- app/models/concerns/enrichable.rb | 9 +- app/models/plaid_account.rb | 162 ------------------ .../investment_balance_processor.rb | 54 +++++- .../plaid_account/investments_processor.rb | 131 ++++++++++++++ .../plaid_account/liabilities_processor.rb | 55 ++++++ app/models/plaid_account/processor.rb | 1 + .../plaid_account/transactions_processor.rb | 40 +++++ .../plaid_entry/transaction_processor.rb | 91 ++++++++++ app/models/plaid_investment_sync.rb | 115 ------------- app/models/plaid_item.rb | 26 +++ app/models/plaid_item/syncer.rb | 79 +-------- app/views/transactions/show.html.erb | 2 +- test/models/plaid_investment_sync_test.rb | 82 --------- 13 files changed, 412 insertions(+), 435 deletions(-) create mode 100644 app/models/plaid_account/investments_processor.rb create mode 100644 app/models/plaid_account/liabilities_processor.rb create mode 100644 app/models/plaid_account/transactions_processor.rb create mode 100644 app/models/plaid_entry/transaction_processor.rb delete mode 100644 app/models/plaid_investment_sync.rb delete mode 100644 test/models/plaid_investment_sync_test.rb diff --git a/app/models/concerns/enrichable.rb b/app/models/concerns/enrichable.rb index 64937a40..be813066 100644 --- a/app/models/concerns/enrichable.rb +++ b/app/models/concerns/enrichable.rb @@ -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| diff --git a/app/models/plaid_account.rb b/app/models/plaid_account.rb index 013ab875..5a1120c2 100644 --- a/app/models/plaid_account.rb +++ b/app/models/plaid_account.rb @@ -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 diff --git a/app/models/plaid_account/investment_balance_processor.rb b/app/models/plaid_account/investment_balance_processor.rb index d6b65ad0..748cb343 100644 --- a/app/models/plaid_account/investment_balance_processor.rb +++ b/app/models/plaid_account/investment_balance_processor.rb @@ -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 diff --git a/app/models/plaid_account/investments_processor.rb b/app/models/plaid_account/investments_processor.rb new file mode 100644 index 00000000..b46a7d7e --- /dev/null +++ b/app/models/plaid_account/investments_processor.rb @@ -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 diff --git a/app/models/plaid_account/liabilities_processor.rb b/app/models/plaid_account/liabilities_processor.rb new file mode 100644 index 00000000..60248260 --- /dev/null +++ b/app/models/plaid_account/liabilities_processor.rb @@ -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 diff --git a/app/models/plaid_account/processor.rb b/app/models/plaid_account/processor.rb index 0cf8e026..21e7e0ed 100644 --- a/app/models/plaid_account/processor.rb +++ b/app/models/plaid_account/processor.rb @@ -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 diff --git a/app/models/plaid_account/transactions_processor.rb b/app/models/plaid_account/transactions_processor.rb new file mode 100644 index 00000000..89fa1f3e --- /dev/null +++ b/app/models/plaid_account/transactions_processor.rb @@ -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 diff --git a/app/models/plaid_entry/transaction_processor.rb b/app/models/plaid_entry/transaction_processor.rb new file mode 100644 index 00000000..059b9640 --- /dev/null +++ b/app/models/plaid_entry/transaction_processor.rb @@ -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 diff --git a/app/models/plaid_investment_sync.rb b/app/models/plaid_investment_sync.rb deleted file mode 100644 index cc0d56a6..00000000 --- a/app/models/plaid_investment_sync.rb +++ /dev/null @@ -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 diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index aed7f4f9..bf4844ba 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -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, diff --git a/app/models/plaid_item/syncer.rb b/app/models/plaid_item/syncer.rb index 92148a94..fe0fbd7f 100644 --- a/app/models/plaid_item/syncer.rb +++ b/app/models/plaid_item/syncer.rb @@ -7,21 +7,17 @@ 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( - parent_sync: sync, - window_start_date: sync.window_start_date, - window_end_date: sync.window_end_date - ) - end + plaid_item.schedule_account_syncs( + parent_sync: sync, + window_start_date: sync.window_start_date, + window_end_date: sync.window_end_date + ) end def perform_post_sync @@ -29,14 +25,6 @@ class PlaidItem::Syncer 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 || [] diff --git a/app/views/transactions/show.html.erb b/app/views/transactions/show.html.erb index 00d32d63..507afa29 100644 --- a/app/views/transactions/show.html.erb +++ b/app/views/transactions/show.html.erb @@ -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"), diff --git a/test/models/plaid_investment_sync_test.rb b/test/models/plaid_investment_sync_test.rb deleted file mode 100644 index f7a3e4e1..00000000 --- a/test/models/plaid_investment_sync_test.rb +++ /dev/null @@ -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