2024-07-10 11:22:59 -04:00
|
|
|
class Account::Balance::Syncer
|
|
|
|
attr_reader :warnings
|
|
|
|
|
|
|
|
def initialize(account, start_date: nil)
|
|
|
|
@account = account
|
|
|
|
@warnings = []
|
|
|
|
@sync_start_date = calculate_sync_start_date(start_date)
|
|
|
|
end
|
|
|
|
|
|
|
|
def run
|
|
|
|
daily_balances = calculate_daily_balances
|
|
|
|
daily_balances += calculate_converted_balances(daily_balances) if account.currency != account.family.currency
|
|
|
|
|
|
|
|
Account::Balance.transaction do
|
|
|
|
upsert_balances!(daily_balances)
|
|
|
|
purge_stale_balances!
|
2024-07-12 13:47:39 -04:00
|
|
|
|
|
|
|
if daily_balances.any?
|
|
|
|
account.reload
|
|
|
|
account.update! balance: daily_balances.select { |db| db.currency == account.currency }.last&.balance
|
|
|
|
end
|
2024-07-10 11:22:59 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
private
|
|
|
|
|
|
|
|
attr_reader :sync_start_date, :account
|
|
|
|
|
|
|
|
def upsert_balances!(balances)
|
|
|
|
balances_to_upsert = balances.map do |balance|
|
|
|
|
{
|
|
|
|
date: balance.date,
|
|
|
|
balance: balance.balance,
|
|
|
|
currency: balance.currency,
|
|
|
|
updated_at: Time.now
|
|
|
|
}
|
|
|
|
end
|
|
|
|
|
|
|
|
account.balances.upsert_all(balances_to_upsert, unique_by: %i[account_id date currency])
|
|
|
|
end
|
|
|
|
|
|
|
|
def purge_stale_balances!
|
|
|
|
account.balances.delete_by("date < ?", account_start_date)
|
|
|
|
end
|
|
|
|
|
|
|
|
def calculate_balance_for_date(date, entries:, prior_balance:)
|
|
|
|
valuation = entries.find { |e| e.date == date && e.account_valuation? }
|
|
|
|
|
|
|
|
return valuation.amount if valuation
|
|
|
|
return derived_sync_start_balance(entries) unless prior_balance
|
|
|
|
|
|
|
|
transactions = entries.select { |e| e.date == date && e.account_transaction? }
|
|
|
|
|
|
|
|
prior_balance - net_transaction_flows(transactions)
|
|
|
|
end
|
|
|
|
|
|
|
|
def calculate_daily_balances
|
|
|
|
entries = account.entries.where("date >= ?", sync_start_date).to_a
|
|
|
|
prior_balance = find_prior_balance
|
|
|
|
|
2024-07-12 13:47:39 -04:00
|
|
|
(sync_start_date..Date.current).map do |date|
|
2024-07-10 11:22:59 -04:00
|
|
|
current_balance = calculate_balance_for_date(date, entries:, prior_balance:)
|
|
|
|
|
|
|
|
prior_balance = current_balance
|
|
|
|
|
|
|
|
build_balance(date, current_balance)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def calculate_converted_balances(balances)
|
|
|
|
from_currency = account.currency
|
|
|
|
to_currency = account.family.currency
|
|
|
|
|
|
|
|
exchange_rates = ExchangeRate.find_rates from: from_currency,
|
|
|
|
to: to_currency,
|
|
|
|
start_date: sync_start_date
|
|
|
|
|
|
|
|
balances.map do |balance|
|
|
|
|
exchange_rate = exchange_rates.find { |er| er.date == balance.date }
|
|
|
|
|
|
|
|
raise Money::ConversionError.new("missing exchange rate from #{from_currency} to #{to_currency} on date #{balance.date}") unless exchange_rate
|
|
|
|
|
|
|
|
build_balance(balance.date, exchange_rate.rate * balance.balance, to_currency)
|
|
|
|
end
|
|
|
|
rescue Money::ConversionError
|
|
|
|
@warnings << "missing exchange rates from #{from_currency} to #{to_currency}"
|
|
|
|
[]
|
|
|
|
end
|
|
|
|
|
|
|
|
def build_balance(date, balance, currency = nil)
|
|
|
|
account.balances.build \
|
|
|
|
date: date,
|
|
|
|
balance: balance,
|
|
|
|
currency: currency || account.currency
|
|
|
|
end
|
|
|
|
|
|
|
|
def derived_sync_start_balance(entries)
|
|
|
|
transactions = entries.select { |e| e.account_transaction? && e.date > sync_start_date }
|
|
|
|
|
|
|
|
account.balance + net_transaction_flows(transactions)
|
|
|
|
end
|
|
|
|
|
|
|
|
def find_prior_balance
|
|
|
|
account.balances.where("date < ?", sync_start_date).order(date: :desc).first&.balance
|
|
|
|
end
|
|
|
|
|
|
|
|
def net_transaction_flows(transactions, target_currency = account.currency)
|
|
|
|
converted_transaction_amounts = transactions.map { |t| t.amount_money.exchange_to(target_currency, date: t.date) }
|
|
|
|
|
|
|
|
flows = converted_transaction_amounts.sum(&:amount)
|
|
|
|
|
|
|
|
account.liability? ? flows * -1 : flows
|
|
|
|
end
|
|
|
|
|
|
|
|
def account_start_date
|
|
|
|
@account_start_date ||= begin
|
|
|
|
oldest_entry_date = account.entries.chronological.first.try(:date)
|
|
|
|
|
|
|
|
return Date.current unless oldest_entry_date
|
|
|
|
|
|
|
|
oldest_entry_is_valuation = account.entries.account_valuations.where(date: oldest_entry_date).exists?
|
|
|
|
|
|
|
|
oldest_entry_date -= 1 unless oldest_entry_is_valuation
|
|
|
|
oldest_entry_date
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def calculate_sync_start_date(provided_start_date)
|
|
|
|
[ provided_start_date, account_start_date ].compact.max
|
|
|
|
end
|
|
|
|
end
|