mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 05:09:38 +02:00
Plaid sync tests and multi-currency investment support (#1531)
* Plaid sync tests and multi-currency investment support * Fix system test * Cleanup * Remove data migration
This commit is contained in:
parent
b2a56aefc1
commit
800eb4c146
21 changed files with 406 additions and 165 deletions
|
@ -23,6 +23,6 @@ class Account::HoldingsController < ApplicationController
|
||||||
|
|
||||||
private
|
private
|
||||||
def set_holding
|
def set_holding
|
||||||
@holding = Current.family.holdings.current.find(params[:id])
|
@holding = Current.family.holdings.find(params[:id])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -118,11 +118,8 @@ class Account < ApplicationRecord
|
||||||
Money.new(balance_amount, currency)
|
Money.new(balance_amount, currency)
|
||||||
end
|
end
|
||||||
|
|
||||||
def owns_ticker?(ticker)
|
def current_holdings
|
||||||
security_id = Security.find_by(ticker: ticker)&.id
|
holdings.where(currency: currency, date: holdings.maximum(:date)).order(amount: :desc)
|
||||||
entries.account_trades
|
|
||||||
.joins("JOIN account_trades ON account_entries.entryable_id = account_trades.id")
|
|
||||||
.where(account_trades: { security_id: security_id }).any?
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def favorable_direction
|
def favorable_direction
|
||||||
|
@ -151,12 +148,4 @@ class Account < ApplicationRecord
|
||||||
entryable: Account::Valuation.new
|
entryable: Account::Valuation.new
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
@ -9,9 +9,6 @@ class Account::Holding < ApplicationRecord
|
||||||
validates :qty, :currency, presence: true
|
validates :qty, :currency, presence: true
|
||||||
|
|
||||||
scope :chronological, -> { order(:date) }
|
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) }
|
scope :for, ->(security) { where(security_id: security).order(:date) }
|
||||||
|
|
||||||
delegate :ticker, to: :security
|
delegate :ticker, to: :security
|
||||||
|
@ -29,7 +26,7 @@ class Account::Holding < ApplicationRecord
|
||||||
|
|
||||||
# Basic approximation of cost-basis
|
# Basic approximation of cost-basis
|
||||||
def avg_cost
|
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)
|
Money.new(avg_cost, currency)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -52,13 +52,15 @@ class Account::HoldingCalculator
|
||||||
|
|
||||||
next if price.blank?
|
next if price.blank?
|
||||||
|
|
||||||
|
converted_price = Money.new(price.price, price.currency).exchange_to(account.currency, fallback_rate: 1).amount
|
||||||
|
|
||||||
account.holdings.build(
|
account.holdings.build(
|
||||||
security: security.dig(:security),
|
security: security.dig(:security),
|
||||||
date: date,
|
date: date,
|
||||||
qty: qty,
|
qty: qty,
|
||||||
price: price.price,
|
price: converted_price,
|
||||||
currency: price.currency,
|
currency: account.currency,
|
||||||
amount: qty * price.price
|
amount: qty * converted_price
|
||||||
)
|
)
|
||||||
end.compact
|
end.compact
|
||||||
end
|
end
|
||||||
|
@ -145,7 +147,7 @@ class Account::HoldingCalculator
|
||||||
def load_current_holding_quantities
|
def load_current_holding_quantities
|
||||||
holding_quantities = load_empty_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
|
holding_quantities[holding.security_id] = holding.qty
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -9,7 +9,7 @@ class Account::Syncer
|
||||||
balances = sync_balances(holdings)
|
balances = sync_balances(holdings)
|
||||||
account.reload
|
account.reload
|
||||||
update_account_info(balances, holdings) unless account.plaid_account_id.present?
|
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
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
@ -37,12 +37,7 @@ class Account::Syncer
|
||||||
current_time = Time.now
|
current_time = Time.now
|
||||||
|
|
||||||
Account.transaction do
|
Account.transaction do
|
||||||
account.holdings.upsert_all(
|
load_holdings(calculated_holdings)
|
||||||
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?
|
|
||||||
|
|
||||||
# Purge outdated holdings
|
# Purge outdated holdings
|
||||||
account.holdings.delete_by("date < ? OR security_id NOT IN (?)", account_start_date, calculated_holdings.map(&:security_id))
|
account.holdings.delete_by("date < ? OR security_id NOT IN (?)", account_start_date, calculated_holdings.map(&:security_id))
|
||||||
|
@ -65,24 +60,7 @@ class Account::Syncer
|
||||||
calculated_balances
|
calculated_balances
|
||||||
end
|
end
|
||||||
|
|
||||||
def convert_foreign_records(balances)
|
def convert_records_to_family_currency(balances, holdings)
|
||||||
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
|
|
||||||
|
|
||||||
from_currency = account.currency
|
from_currency = account.currency
|
||||||
to_currency = account.family.currency
|
to_currency = account.family.currency
|
||||||
|
|
||||||
|
@ -92,7 +70,7 @@ class Account::Syncer
|
||||||
start_date: balances.first.date
|
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 }
|
exchange_rate = exchange_rates.find { |er| er.date == balance.date }
|
||||||
|
|
||||||
account.balances.build(
|
account.balances.build(
|
||||||
|
@ -101,5 +79,41 @@ class Account::Syncer
|
||||||
currency: to_currency
|
currency: to_currency
|
||||||
) if exchange_rate.present?
|
) if exchange_rate.present?
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|
|
@ -23,8 +23,4 @@ class Investment < ApplicationRecord
|
||||||
def icon
|
def icon
|
||||||
"line-chart"
|
"line-chart"
|
||||||
end
|
end
|
||||||
|
|
||||||
def post_sync
|
|
||||||
broadcast_refresh_to account.family
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -45,50 +45,7 @@ class PlaidAccount < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def sync_investments!(transactions:, holdings:, securities:)
|
def sync_investments!(transactions:, holdings:, securities:)
|
||||||
transactions.each do |transaction|
|
PlaidInvestmentSync.new(self).sync!(transactions:, holdings:, securities:)
|
||||||
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
|
end
|
||||||
|
|
||||||
def sync_credit_data!(plaid_credit_data)
|
def sync_credit_data!(plaid_credit_data)
|
||||||
|
@ -159,25 +116,6 @@ class PlaidAccount < ApplicationRecord
|
||||||
plaid_item.family
|
plaid_item.family
|
||||||
end
|
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)
|
def transfer?(plaid_txn)
|
||||||
transfer_categories = [ "TRANSFER_IN", "TRANSFER_OUT", "LOAN_PAYMENTS" ]
|
transfer_categories = [ "TRANSFER_IN", "TRANSFER_OUT", "LOAN_PAYMENTS" ]
|
||||||
|
|
||||||
|
|
95
app/models/plaid_investment_sync.rb
Normal file
95
app/models/plaid_investment_sync.rb
Normal file
|
@ -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
|
|
@ -134,10 +134,12 @@ class Provider::Plaid
|
||||||
|
|
||||||
def get_item_investments(item, start_date: nil, end_date: Date.current)
|
def get_item_investments(item, start_date: nil, end_date: Date.current)
|
||||||
start_date = start_date || MAX_HISTORY_DAYS.days.ago.to_date
|
start_date = start_date || MAX_HISTORY_DAYS.days.ago.to_date
|
||||||
holdings = get_item_holdings(item)
|
holdings, holding_securities = get_item_holdings(item)
|
||||||
transactions, securities = get_item_investment_transactions(item, start_date:, end_date:)
|
transactions, transaction_securities = get_item_investment_transactions(item, start_date:, end_date:)
|
||||||
|
|
||||||
InvestmentsResponse.new(holdings:, transactions:, securities:)
|
merged_securities = ((holding_securities || []) + (transaction_securities || [])).uniq { |s| s.security_id }
|
||||||
|
|
||||||
|
InvestmentsResponse.new(holdings:, transactions:, securities: merged_securities)
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_item_liabilities(item)
|
def get_item_liabilities(item)
|
||||||
|
@ -154,15 +156,7 @@ class Provider::Plaid
|
||||||
request = Plaid::InvestmentsHoldingsGetRequest.new({ access_token: item.access_token })
|
request = Plaid::InvestmentsHoldingsGetRequest.new({ access_token: item.access_token })
|
||||||
response = client.investments_holdings_get(request)
|
response = client.investments_holdings_get(request)
|
||||||
|
|
||||||
securities_by_id = response.securities.index_by(&:security_id)
|
[ response.holdings, response.securities ]
|
||||||
accounts_by_id = response.accounts.index_by(&:account_id)
|
|
||||||
|
|
||||||
response.holdings.each do |holding|
|
|
||||||
holding.define_singleton_method(:security) { securities_by_id[holding.security_id] }
|
|
||||||
holding.define_singleton_method(:account) { accounts_by_id[holding.account_id] }
|
|
||||||
end
|
|
||||||
|
|
||||||
response.holdings
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_item_investment_transactions(item, start_date:, end_date:)
|
def get_item_investment_transactions(item, start_date:, end_date:)
|
||||||
|
@ -179,15 +173,8 @@ class Provider::Plaid
|
||||||
)
|
)
|
||||||
|
|
||||||
response = client.investments_transactions_get(request)
|
response = client.investments_transactions_get(request)
|
||||||
securities_by_id = response.securities.index_by(&:security_id)
|
|
||||||
accounts_by_id = response.accounts.index_by(&:account_id)
|
|
||||||
|
|
||||||
response.investment_transactions.each do |t|
|
|
||||||
t.define_singleton_method(:security) { securities_by_id[t.security_id] }
|
|
||||||
t.define_singleton_method(:account) { accounts_by_id[t.account_id] }
|
|
||||||
transactions << t
|
|
||||||
end
|
|
||||||
|
|
||||||
|
transactions += response.investment_transactions
|
||||||
securities += response.securities
|
securities += response.securities
|
||||||
|
|
||||||
break if transactions.length >= response.total_investment_transactions
|
break if transactions.length >= response.total_investment_transactions
|
||||||
|
|
|
@ -43,18 +43,23 @@ class Provider::Synth
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch_security_prices(ticker:, mic_code:, start_date:, end_date:)
|
def fetch_security_prices(ticker:, start_date:, end_date:, mic_code: nil)
|
||||||
prices = paginate(
|
params = {
|
||||||
"#{base_url}/tickers/#{ticker}/open-close",
|
|
||||||
mic_code: mic_code,
|
|
||||||
start_date: start_date,
|
start_date: start_date,
|
||||||
end_date: end_date
|
end_date: end_date
|
||||||
|
}
|
||||||
|
|
||||||
|
params[:mic_code] = mic_code if mic_code.present?
|
||||||
|
|
||||||
|
prices = paginate(
|
||||||
|
"#{base_url}/tickers/#{ticker}/open-close",
|
||||||
|
params
|
||||||
) do |body|
|
) do |body|
|
||||||
body.dig("prices").map do |price|
|
body.dig("prices").map do |price|
|
||||||
{
|
{
|
||||||
date: price.dig("date"),
|
date: price.dig("date"),
|
||||||
price: price.dig("close")&.to_f || price.dig("open")&.to_f,
|
price: price.dig("close")&.to_f || price.dig("open")&.to_f,
|
||||||
currency: "USD"
|
currency: price.dig("currency") || "USD"
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -23,7 +23,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-2 text-right">
|
<div class="col-span-2 text-right">
|
||||||
<%= tag.p format_money account.cash_balance %>
|
<%= tag.p format_money account.cash_balance_money %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-2 text-right">
|
<div class="col-span-2 text-right">
|
||||||
|
|
|
@ -21,10 +21,10 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg bg-white border-alpha-black-25 shadow-xs">
|
<div class="rounded-lg bg-white border-alpha-black-25 shadow-xs">
|
||||||
<% if @account.holdings.current.any? %>
|
<% if @account.current_holdings.any? %>
|
||||||
<%= render "account/holdings/cash", account: @account %>
|
<%= render "account/holdings/cash", account: @account %>
|
||||||
<%= render "account/holdings/ruler" %>
|
<%= render "account/holdings/ruler" %>
|
||||||
<%= render partial: "account/holdings/holding", collection: @account.holdings.current, spacer_template: "ruler" %>
|
<%= render partial: "account/holdings/holding", collection: @account.current_holdings, spacer_template: "ruler" %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<p class="text-gray-500 text-sm p-4"><%= t(".no_holdings") %></p>
|
<p class="text-gray-500 text-sm p-4"><%= t(".no_holdings") %></p>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -20,7 +20,13 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div class="flex items-center gap-3 ml-auto">
|
<div class="flex items-center gap-3 ml-auto">
|
||||||
<% unless account.plaid_account_id.present? %>
|
<% if account.plaid_account_id.present? %>
|
||||||
|
<% if Rails.env.development? %>
|
||||||
|
<%= button_to sync_plaid_item_path(account.plaid_account.plaid_item), disabled: account.syncing?, data: { turbo: false }, class: "flex items-center gap-2", title: "Sync Account" do %>
|
||||||
|
<%= lucide_icon "refresh-cw", class: "w-4 h-4 text-gray-500 hover:text-gray-400" %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
<%= button_to sync_account_path(account), disabled: account.syncing?, data: { turbo: false }, class: "flex items-center gap-2", title: "Sync Account" do %>
|
<%= button_to sync_account_path(account), disabled: account.syncing?, data: { turbo: false }, class: "flex items-center gap-2", title: "Sync Account" do %>
|
||||||
<%= lucide_icon "refresh-cw", class: "w-4 h-4 text-gray-500 hover:text-gray-400" %>
|
<%= lucide_icon "refresh-cw", class: "w-4 h-4 text-gray-500 hover:text-gray-400" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<%# locals: (progress:, radius: 7, stroke: 2, text_class: "text-green-500") %>
|
<%# locals: (progress:, radius: 7, stroke: 2, text_class: "text-green-500") %>
|
||||||
|
|
||||||
<%
|
<%
|
||||||
circumference = Math::PI * 2 * radius
|
circumference = Math::PI * 2 * radius
|
||||||
progress_percent = progress.clamp(0, 100)
|
progress_percent = progress.clamp(0, 100)
|
||||||
stroke_dashoffset = ((100 - progress_percent) * circumference) / 100
|
stroke_dashoffset = ((100 - progress_percent) * circumference) / 100
|
||||||
center = radius + stroke / 2
|
center = radius + stroke / 2
|
||||||
|
@ -9,16 +9,15 @@
|
||||||
|
|
||||||
<svg width="<%= radius * 2 + stroke %>" height="<%= radius * 2 + stroke %>">
|
<svg width="<%= radius * 2 + stroke %>" height="<%= radius * 2 + stroke %>">
|
||||||
<!-- Background Circle -->
|
<!-- Background Circle -->
|
||||||
<circle
|
<circle
|
||||||
class="fill-transparent stroke-current text-gray-300"
|
class="fill-transparent stroke-current text-gray-300"
|
||||||
r="<%= radius %>"
|
r="<%= radius %>"
|
||||||
cx="<%= center %>"
|
cx="<%= center %>"
|
||||||
cy="<%= center %>"
|
cy="<%= center %>"
|
||||||
stroke-width="<%= stroke %>"
|
stroke-width="<%= stroke %>"></circle>
|
||||||
></circle>
|
|
||||||
|
|
||||||
<!-- Foreground Circle -->
|
<!-- Foreground Circle -->
|
||||||
<circle
|
<circle
|
||||||
class="fill-transparent stroke-current <%= text_class %>"
|
class="fill-transparent stroke-current <%= text_class %>"
|
||||||
r="<%= radius %>"
|
r="<%= radius %>"
|
||||||
cx="<%= center %>"
|
cx="<%= center %>"
|
||||||
|
@ -26,6 +25,5 @@
|
||||||
stroke-width="<%= stroke %>"
|
stroke-width="<%= stroke %>"
|
||||||
stroke-dasharray="<%= circumference %>"
|
stroke-dasharray="<%= circumference %>"
|
||||||
stroke-dashoffset="<%= stroke_dashoffset %>"
|
stroke-dashoffset="<%= stroke_dashoffset %>"
|
||||||
transform="rotate(-90, <%= center %>, <%= center %>)"
|
transform="rotate(-90, <%= center %>, <%= center %>)"></circle>
|
||||||
></circle>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|
|
@ -4,7 +4,7 @@ class Account::HoldingsControllerTest < ActionDispatch::IntegrationTest
|
||||||
setup do
|
setup do
|
||||||
sign_in users(:family_admin)
|
sign_in users(:family_admin)
|
||||||
@account = accounts(:investment)
|
@account = accounts(:investment)
|
||||||
@holding = @account.holdings.current.first
|
@holding = @account.holdings.first
|
||||||
end
|
end
|
||||||
|
|
||||||
test "gets holdings" do
|
test "gets holdings" do
|
||||||
|
|
3
test/fixtures/securities.yml
vendored
3
test/fixtures/securities.yml
vendored
|
@ -2,8 +2,11 @@ aapl:
|
||||||
ticker: AAPL
|
ticker: AAPL
|
||||||
name: Apple
|
name: Apple
|
||||||
exchange_mic: XNAS
|
exchange_mic: XNAS
|
||||||
|
country_code: US
|
||||||
|
|
||||||
msft:
|
msft:
|
||||||
ticker: MSFT
|
ticker: MSFT
|
||||||
name: Microsoft
|
name: Microsoft
|
||||||
exchange_mic: XNAS
|
exchange_mic: XNAS
|
||||||
|
country_code: US
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,7 @@ class Account::SyncerTest < ActiveSupport::TestCase
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "converts foreign account balances to family currency" do
|
test "converts foreign account balances and holdings to family currency" do
|
||||||
@account.family.update! currency: "USD"
|
@account.family.update! currency: "USD"
|
||||||
@account.update! currency: "EUR"
|
@account.update! currency: "EUR"
|
||||||
|
|
||||||
|
@ -27,10 +27,19 @@ class Account::SyncerTest < ActiveSupport::TestCase
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Account::HoldingCalculator.any_instance.expects(:calculate).returns(
|
||||||
|
[
|
||||||
|
Account::Holding.new(security: securities(:aapl), date: 1.day.ago.to_date, amount: 500, currency: "EUR"),
|
||||||
|
Account::Holding.new(security: securities(:aapl), date: Date.current, amount: 500, currency: "EUR")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
Account::Syncer.new(@account).run
|
Account::Syncer.new(@account).run
|
||||||
|
|
||||||
assert_equal [ 1000, 1000 ], @account.balances.where(currency: "EUR").chronological.map(&:balance)
|
assert_equal [ 1000, 1000 ], @account.balances.where(currency: "EUR").chronological.map(&:balance)
|
||||||
assert_equal [ 1200, 2000 ], @account.balances.where(currency: "USD").chronological.map(&:balance)
|
assert_equal [ 1200, 2000 ], @account.balances.where(currency: "USD").chronological.map(&:balance)
|
||||||
|
assert_equal [ 500, 500 ], @account.holdings.where(currency: "EUR").chronological.map(&:amount)
|
||||||
|
assert_equal [ 600, 1000 ], @account.holdings.where(currency: "USD").chronological.map(&:amount)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "purges stale balances and holdings" do
|
test "purges stale balances and holdings" do
|
||||||
|
|
|
@ -59,13 +59,4 @@ class AccountTest < ActiveSupport::TestCase
|
||||||
assert_equal 0, @account.series(currency: "NZD").values.count
|
assert_equal 0, @account.series(currency: "NZD").values.count
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "calculates shares owned of holding for date" do
|
|
||||||
account = accounts(:investment)
|
|
||||||
security = securities(:aapl)
|
|
||||||
|
|
||||||
assert_equal 10, account.holding_qty(security, date: Date.current)
|
|
||||||
assert_equal 10, account.holding_qty(security, date: 1.day.ago.to_date)
|
|
||||||
assert_equal 0, account.holding_qty(security, date: 2.days.ago.to_date)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
82
test/models/plaid_investment_sync_test.rb
Normal file
82
test/models/plaid_investment_sync_test.rb
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
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 -> { Account::Trade.count } => 1,
|
||||||
|
-> { Account::Transaction.count } => 0,
|
||||||
|
-> { Account::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 -> { Account::Trade.count } => 0,
|
||||||
|
-> { Account::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
|
128
test/support/plaid_test_helper.rb
Normal file
128
test/support/plaid_test_helper.rb
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
require "ostruct"
|
||||||
|
|
||||||
|
module PlaidTestHelper
|
||||||
|
PLAID_TEST_ACCOUNT_ID = "plaid_test_account_id"
|
||||||
|
PLAID_TEST_CASH_SECURITY_ID = "plaid_test_cash_security_id"
|
||||||
|
|
||||||
|
# Special case
|
||||||
|
def create_plaid_cash_security(attributes = {})
|
||||||
|
default_attributes = {
|
||||||
|
close_price: nil,
|
||||||
|
close_price_as_of: nil,
|
||||||
|
cusip: nil,
|
||||||
|
fixed_income: nil,
|
||||||
|
industry: nil,
|
||||||
|
institution_id: nil,
|
||||||
|
institution_security_id: nil,
|
||||||
|
is_cash_equivalent: false, # Plaid sometimes returns false here (bad data), so we should not rely on it
|
||||||
|
isin: nil,
|
||||||
|
iso_currency_code: "USD",
|
||||||
|
market_identifier_code: nil,
|
||||||
|
name: "US Dollar",
|
||||||
|
option_contract: nil,
|
||||||
|
proxy_security_id: nil,
|
||||||
|
sector: nil,
|
||||||
|
security_id: PLAID_TEST_CASH_SECURITY_ID,
|
||||||
|
sedol: nil,
|
||||||
|
ticker_symbol: "CUR:USD",
|
||||||
|
type: "cash",
|
||||||
|
unofficial_currency_code: nil,
|
||||||
|
update_datetime: nil
|
||||||
|
}
|
||||||
|
|
||||||
|
OpenStruct.new(
|
||||||
|
default_attributes.merge(attributes)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_plaid_security(attributes = {})
|
||||||
|
default_attributes = {
|
||||||
|
close_price: 606.71,
|
||||||
|
close_price_as_of: Date.current,
|
||||||
|
cusip: nil,
|
||||||
|
fixed_income: nil,
|
||||||
|
industry: "Mutual Funds",
|
||||||
|
institution_id: nil,
|
||||||
|
institution_security_id: nil,
|
||||||
|
is_cash_equivalent: false,
|
||||||
|
isin: nil,
|
||||||
|
iso_currency_code: "USD",
|
||||||
|
market_identifier_code: "XNAS",
|
||||||
|
name: "iShares S&P 500 Index",
|
||||||
|
option_contract: nil,
|
||||||
|
proxy_security_id: nil,
|
||||||
|
sector: "Financial",
|
||||||
|
security_id: "plaid_test_security_id",
|
||||||
|
sedol: "2593025",
|
||||||
|
ticker_symbol: "IVV",
|
||||||
|
type: "etf",
|
||||||
|
unofficial_currency_code: nil,
|
||||||
|
update_datetime: nil
|
||||||
|
}
|
||||||
|
|
||||||
|
OpenStruct.new(
|
||||||
|
default_attributes.merge(attributes)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_plaid_cash_holding(attributes = {})
|
||||||
|
default_attributes = {
|
||||||
|
account_id: PLAID_TEST_ACCOUNT_ID,
|
||||||
|
cost_basis: 1000,
|
||||||
|
institution_price: 1,
|
||||||
|
institution_price_as_of: Date.current,
|
||||||
|
iso_currency_code: "USD",
|
||||||
|
quantity: 1000,
|
||||||
|
security_id: PLAID_TEST_CASH_SECURITY_ID,
|
||||||
|
unofficial_currency_code: nil,
|
||||||
|
vested_quantity: nil,
|
||||||
|
vested_value: nil
|
||||||
|
}
|
||||||
|
|
||||||
|
OpenStruct.new(
|
||||||
|
default_attributes.merge(attributes)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_plaid_holding(attributes = {})
|
||||||
|
default_attributes = {
|
||||||
|
account_id: PLAID_TEST_ACCOUNT_ID,
|
||||||
|
cost_basis: 2000,
|
||||||
|
institution_price: 200,
|
||||||
|
institution_price_as_of: Date.current,
|
||||||
|
iso_currency_code: "USD",
|
||||||
|
quantity: 10,
|
||||||
|
security_id: "plaid_test_security_id",
|
||||||
|
unofficial_currency_code: nil,
|
||||||
|
vested_quantity: nil,
|
||||||
|
vested_value: nil
|
||||||
|
}
|
||||||
|
|
||||||
|
OpenStruct.new(
|
||||||
|
default_attributes.merge(attributes)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_plaid_investment_transaction(attributes = {})
|
||||||
|
default_attributes = {
|
||||||
|
account_id: PLAID_TEST_ACCOUNT_ID,
|
||||||
|
amount: 500,
|
||||||
|
cancel_transaction_id: nil,
|
||||||
|
date: 5.days.ago.to_date,
|
||||||
|
fees: 0,
|
||||||
|
investment_transaction_id: "plaid_test_investment_transaction_id",
|
||||||
|
iso_currency_code: "USD",
|
||||||
|
name: "Buy 100 shares of IVV",
|
||||||
|
price: 606.71,
|
||||||
|
quantity: 100,
|
||||||
|
security_id: "plaid_test_security_id",
|
||||||
|
type: "buy",
|
||||||
|
subtype: "buy",
|
||||||
|
unofficial_currency_code: nil
|
||||||
|
}
|
||||||
|
|
||||||
|
OpenStruct.new(
|
||||||
|
default_attributes.merge(attributes)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
|
@ -16,7 +16,8 @@ class TradesTest < ApplicationSystemTestCase
|
||||||
name: "Apple Inc.",
|
name: "Apple Inc.",
|
||||||
logo_url: "https://logo.synthfinance.com/ticker/AAPL",
|
logo_url: "https://logo.synthfinance.com/ticker/AAPL",
|
||||||
exchange_acronym: "NASDAQ",
|
exchange_acronym: "NASDAQ",
|
||||||
exchange_mic: "XNAS"
|
exchange_mic: "XNAS",
|
||||||
|
country_code: "US"
|
||||||
)
|
)
|
||||||
])
|
])
|
||||||
end
|
end
|
||||||
|
@ -43,7 +44,7 @@ class TradesTest < ApplicationSystemTestCase
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can create sell transaction" do
|
test "can create sell transaction" do
|
||||||
aapl = @account.holdings.current.find { |h| h.security.ticker == "AAPL" }
|
aapl = @account.holdings.find { |h| h.security.ticker == "AAPL" }
|
||||||
|
|
||||||
open_new_trade_modal
|
open_new_trade_modal
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue