Skip to content

Commit

Permalink
Plaid sync tests and multi-currency investment support (#1531)
Browse files Browse the repository at this point in the history
* Plaid sync tests and multi-currency investment support

* Fix system test

* Cleanup

* Remove data migration
  • Loading branch information
zachgoll authored Dec 12, 2024
1 parent b2a56ae commit 800eb4c
Show file tree
Hide file tree
Showing 21 changed files with 406 additions and 165 deletions.
2 changes: 1 addition & 1 deletion app/controllers/account/holdings_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,6 @@ def destroy

private
def set_holding
@holding = Current.family.holdings.current.find(params[:id])
@holding = Current.family.holdings.find(params[:id])
end
end
15 changes: 2 additions & 13 deletions app/models/account.rb
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,8 @@ def original_balance
Money.new(balance_amount, currency)
end

def owns_ticker?(ticker)
security_id = Security.find_by(ticker: ticker)&.id
entries.account_trades
.joins("JOIN account_trades ON account_entries.entryable_id = account_trades.id")
.where(account_trades: { security_id: security_id }).any?
def current_holdings
holdings.where(currency: currency, date: holdings.maximum(:date)).order(amount: :desc)
end

def favorable_direction
Expand Down Expand Up @@ -151,12 +148,4 @@ def update_balance!(balance)
entryable: Account::Valuation.new
end
end

def holding_qty(security, date: Date.current)
entries.account_trades
.joins("JOIN account_trades ON account_entries.entryable_id = account_trades.id")
.where(account_trades: { security_id: security.id })
.where("account_entries.date <= ?", date)
.sum("account_trades.qty")
end
end
5 changes: 1 addition & 4 deletions app/models/account/holding.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,6 @@ class Account::Holding < ApplicationRecord
validates :qty, :currency, presence: true

scope :chronological, -> { order(:date) }
scope :current, -> { where(date: Date.current).order(amount: :desc) }
scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) }
scope :known_value, -> { where.not(amount: nil) }
scope :for, ->(security) { where(security_id: security).order(:date) }

delegate :ticker, to: :security
Expand All @@ -29,7 +26,7 @@ def weight

# Basic approximation of cost-basis
def avg_cost
avg_cost = account.holdings.for(security).where("date <= ?", date).average(:price)
avg_cost = account.holdings.for(security).where(currency: currency).where("date <= ?", date).average(:price)
Money.new(avg_cost, currency)
end

Expand Down
10 changes: 6 additions & 4 deletions app/models/account/holding_calculator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,15 @@ def generate_holding_records(portfolio, date)

next if price.blank?

converted_price = Money.new(price.price, price.currency).exchange_to(account.currency, fallback_rate: 1).amount

account.holdings.build(
security: security.dig(:security),
date: date,
qty: qty,
price: price.price,
currency: price.currency,
amount: qty * price.price
price: converted_price,
currency: account.currency,
amount: qty * converted_price
)
end.compact
end
Expand Down Expand Up @@ -145,7 +147,7 @@ def load_empty_holding_quantities
def load_current_holding_quantities
holding_quantities = load_empty_holding_quantities

account.holdings.where(date: Date.current).map do |holding|
account.holdings.where(date: Date.current, currency: account.currency).map do |holding|
holding_quantities[holding.security_id] = holding.qty
end

Expand Down
66 changes: 40 additions & 26 deletions app/models/account/syncer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ def run
balances = sync_balances(holdings)
account.reload
update_account_info(balances, holdings) unless account.plaid_account_id.present?
convert_foreign_records(balances)
convert_records_to_family_currency(balances, holdings) unless account.currency == account.family.currency
end

private
Expand Down Expand Up @@ -37,12 +37,7 @@ def sync_holdings
current_time = Time.now

Account.transaction do
account.holdings.upsert_all(
calculated_holdings.map { |h| h.attributes
.slice("date", "currency", "qty", "price", "amount", "security_id")
.merge("updated_at" => current_time) },
unique_by: %i[account_id security_id date currency]
) if calculated_holdings.any?
load_holdings(calculated_holdings)

# Purge outdated holdings
account.holdings.delete_by("date < ? OR security_id NOT IN (?)", account_start_date, calculated_holdings.map(&:security_id))
Expand All @@ -65,24 +60,7 @@ def sync_balances(holdings)
calculated_balances
end

def convert_foreign_records(balances)
converted_balances = convert_balances(balances)
load_balances(converted_balances)
end

def load_balances(balances)
current_time = Time.now
account.balances.upsert_all(
balances.map { |b| b.attributes
.slice("date", "balance", "cash_balance", "currency")
.merge("updated_at" => current_time) },
unique_by: %i[account_id date currency]
) if balances.any?
end

def convert_balances(balances)
return [] if account.currency == account.family.currency

def convert_records_to_family_currency(balances, holdings)
from_currency = account.currency
to_currency = account.family.currency

Expand All @@ -92,7 +70,7 @@ def convert_balances(balances)
start_date: balances.first.date
)

balances.map do |balance|
converted_balances = balances.map do |balance|
exchange_rate = exchange_rates.find { |er| er.date == balance.date }

account.balances.build(
Expand All @@ -101,5 +79,41 @@ def convert_balances(balances)
currency: to_currency
) if exchange_rate.present?
end

converted_holdings = holdings.map do |holding|
exchange_rate = exchange_rates.find { |er| er.date == holding.date }

account.holdings.build(
security: holding.security,
date: holding.date,
amount: exchange_rate.rate * holding.amount,
currency: to_currency
) if exchange_rate.present?
end

Account.transaction do
load_balances(converted_balances)
load_holdings(converted_holdings)
end
end

def load_balances(balances = [])
current_time = Time.now
account.balances.upsert_all(
balances.map { |b| b.attributes
.slice("date", "balance", "cash_balance", "currency")
.merge("updated_at" => current_time) },
unique_by: %i[account_id date currency]
)
end

def load_holdings(holdings = [])
current_time = Time.now
account.holdings.upsert_all(
holdings.map { |h| h.attributes
.slice("date", "currency", "qty", "price", "amount", "security_id")
.merge("updated_at" => current_time) },
unique_by: %i[account_id security_id date currency]
)
end
end
4 changes: 0 additions & 4 deletions app/models/investment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,4 @@ def color
def icon
"line-chart"
end

def post_sync
broadcast_refresh_to account.family
end
end
64 changes: 1 addition & 63 deletions app/models/plaid_account.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,50 +45,7 @@ def sync_account_data!(plaid_account_data)
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
PlaidInvestmentSync.new(self).sync!(transactions:, holdings:, securities:)
end

def sync_credit_data!(plaid_credit_data)
Expand Down Expand Up @@ -159,25 +116,6 @@ def family
plaid_item.family
end

def get_security(plaid_security, securities)
return nil if plaid_security.nil?

security = if plaid_security.ticker_symbol.present?
plaid_security
else
securities.find { |s| s.security_id == plaid_security.proxy_security_id }
end

return nil if security.nil? || security.ticker_symbol.blank?
return nil if security.ticker_symbol == "CUR:USD" # Internally, we do not consider cash a "holding" and track it separately

Security.find_or_create_by!(
ticker: security.ticker_symbol,
exchange_mic: security.market_identifier_code || "XNAS",
country_code: "US"
)
end

def transfer?(plaid_txn)
transfer_categories = [ "TRANSFER_IN", "TRANSFER_OUT", "LOAN_PAYMENTS" ]

Expand Down
95 changes: 95 additions & 0 deletions app/models/plaid_investment_sync.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
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
sync_transactions!
sync_holdings!
end
end

private
attr_reader :transactions, :holdings, :securities

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.marked_as_transfer = transaction.subtype.in?(%w[deposit withdrawal])
t.entryable = Account::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 = Account::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?

security = Security.find_or_create_by!(
ticker: plaid_security.ticker_symbol,
exchange_mic: plaid_security.market_identifier_code || "XNAS",
country_code: "US"
) unless plaid_security.ticker_symbol == "CUR:USD" # internally, we do not consider cash a security and track it separately

[ security, plaid_security ]
end
end
Loading

0 comments on commit 800eb4c

Please sign in to comment.