1
0
Fork 0
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:
Zach Gollwitzer 2025-05-20 19:03:37 -04:00
parent 4069dc0ec6
commit 3bd7baf2f5
13 changed files with 412 additions and 435 deletions

View file

@ -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|

View file

@ -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

View file

@ -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

View 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

View 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

View file

@ -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

View 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

View 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

View file

@ -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

View file

@ -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,

View file

@ -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 || []

View file

@ -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"),

View file

@ -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