mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-02 20:15:22 +02:00
Handle missing exchange rate provider, allow fallback for missing rates (#955)
* Clean up exchange rate logic * Remove stale method
This commit is contained in:
parent
bef335c631
commit
6767aaed1d
20 changed files with 383 additions and 609 deletions
|
@ -28,6 +28,51 @@ class Account < ApplicationRecord
|
|||
|
||||
delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy
|
||||
|
||||
class << self
|
||||
def by_group(period: Period.all, currency: Money.default_currency)
|
||||
grouped_accounts = { assets: ValueGroup.new("Assets", currency), liabilities: ValueGroup.new("Liabilities", currency) }
|
||||
|
||||
Accountable.by_classification.each do |classification, types|
|
||||
types.each do |type|
|
||||
group = grouped_accounts[classification.to_sym].add_child_group(type, currency)
|
||||
self.where(accountable_type: type).each do |account|
|
||||
group.add_value_node(
|
||||
account,
|
||||
account.balance_money.exchange_to(currency, fallback_rate: 0),
|
||||
account.series(period: period, currency: currency)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
grouped_accounts
|
||||
end
|
||||
|
||||
def create_with_optional_start_balance!(attributes:, start_date: nil, start_balance: nil)
|
||||
account = self.new(attributes.except(:accountable_type))
|
||||
account.accountable = Accountable.from_type(attributes[:accountable_type])&.new
|
||||
|
||||
# Always build the initial valuation
|
||||
account.entries.build \
|
||||
date: Date.current,
|
||||
amount: attributes[:balance],
|
||||
currency: account.currency,
|
||||
entryable: Account::Valuation.new
|
||||
|
||||
# Conditionally build the optional start valuation
|
||||
if start_date.present? && start_balance.present?
|
||||
account.entries.build \
|
||||
date: start_date,
|
||||
amount: start_balance,
|
||||
currency: account.currency,
|
||||
entryable: Account::Valuation.new
|
||||
end
|
||||
|
||||
account.save!
|
||||
account
|
||||
end
|
||||
end
|
||||
|
||||
def balance_on(date)
|
||||
balances.where("date <= ?", date).order(date: :desc).first&.balance
|
||||
end
|
||||
|
@ -50,57 +95,11 @@ class Account < ApplicationRecord
|
|||
balance_series = balances.in_period(period).where(currency: Money::Currency.new(currency).iso_code)
|
||||
|
||||
if balance_series.empty? && period.date_range.end == Date.current
|
||||
converted_balance = balance_money.exchange_to(currency)
|
||||
if converted_balance
|
||||
TimeSeries.new([ { date: Date.current, value: converted_balance } ])
|
||||
else
|
||||
TimeSeries.new([])
|
||||
end
|
||||
TimeSeries.new([ { date: Date.current, value: balance_money.exchange_to(currency) } ])
|
||||
else
|
||||
TimeSeries.from_collection(balance_series, :balance_money)
|
||||
end
|
||||
end
|
||||
|
||||
def self.by_group(period: Period.all, currency: Money.default_currency)
|
||||
grouped_accounts = { assets: ValueGroup.new("Assets", currency), liabilities: ValueGroup.new("Liabilities", currency) }
|
||||
|
||||
Accountable.by_classification.each do |classification, types|
|
||||
types.each do |type|
|
||||
group = grouped_accounts[classification.to_sym].add_child_group(type, currency)
|
||||
self.where(accountable_type: type).each do |account|
|
||||
value_node = group.add_value_node(
|
||||
account,
|
||||
account.balance_money.exchange_to(currency) || Money.new(0, currency),
|
||||
account.series(period: period, currency: currency)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
grouped_accounts
|
||||
end
|
||||
|
||||
def self.create_with_optional_start_balance!(attributes:, start_date: nil, start_balance: nil)
|
||||
account = self.new(attributes.except(:accountable_type))
|
||||
account.accountable = Accountable.from_type(attributes[:accountable_type])&.new
|
||||
|
||||
# Always build the initial valuation
|
||||
account.entries.build \
|
||||
date: Date.current,
|
||||
amount: attributes[:balance],
|
||||
currency: account.currency,
|
||||
entryable: Account::Valuation.new
|
||||
|
||||
# Conditionally build the optional start valuation
|
||||
if start_date.present? && start_balance.present?
|
||||
account.entries.build \
|
||||
date: start_date,
|
||||
amount: start_balance,
|
||||
currency: account.currency,
|
||||
entryable: Account::Valuation.new
|
||||
end
|
||||
|
||||
account.save!
|
||||
account
|
||||
rescue Money::ConversionError
|
||||
TimeSeries.new([])
|
||||
end
|
||||
end
|
||||
|
|
|
@ -72,10 +72,10 @@ class Account::Balance::Calculator
|
|||
end
|
||||
|
||||
def convert_balances_to_family_currency(balances)
|
||||
rates = ExchangeRate.get_rates(
|
||||
account.currency,
|
||||
account.family.currency,
|
||||
calc_start_date..Date.current
|
||||
rates = ExchangeRate.find_rates(
|
||||
from: account.currency,
|
||||
to: account.family.currency,
|
||||
start_date: calc_start_date
|
||||
).to_a
|
||||
|
||||
# Abort conversion if some required rates are missing
|
||||
|
@ -84,8 +84,9 @@ class Account::Balance::Calculator
|
|||
return []
|
||||
end
|
||||
|
||||
balances.map.with_index do |balance, index|
|
||||
converted_balance = balance[:balance] * rates[index].rate
|
||||
balances.map do |balance|
|
||||
rate = rates.find { |r| r.date == balance[:date] }
|
||||
converted_balance = balance[:balance] * rate&.rate
|
||||
{ date: balance[:date], balance: converted_balance, currency: account.family.currency, updated_at: Time.current }
|
||||
end
|
||||
end
|
||||
|
|
|
@ -22,7 +22,7 @@ class Account::Entry < ApplicationRecord
|
|||
"account_entries.*",
|
||||
"account_entries.amount * COALESCE(er.rate, 1) AS converted_amount"
|
||||
)
|
||||
.joins(sanitize_sql_array([ "LEFT JOIN exchange_rates er ON account_entries.date = er.date AND account_entries.currency = er.base_currency AND er.converted_currency = ?", currency ]))
|
||||
.joins(sanitize_sql_array([ "LEFT JOIN exchange_rates er ON account_entries.date = er.date AND account_entries.currency = er.from_currency AND er.to_currency = ?", currency ]))
|
||||
.where("er.rate IS NOT NULL OR account_entries.currency = ?", currency)
|
||||
}
|
||||
|
||||
|
|
|
@ -65,10 +65,10 @@ module Account::Syncable
|
|||
return if rate_candidates.blank?
|
||||
|
||||
existing_rates = ExchangeRate.where(
|
||||
base_currency: rate_candidates.map { |rc| rc[:from_currency] },
|
||||
converted_currency: rate_candidates.map { |rc| rc[:to_currency] },
|
||||
from_currency: rate_candidates.map { |rc| rc[:from_currency] },
|
||||
to_currency: rate_candidates.map { |rc| rc[:to_currency] },
|
||||
date: rate_candidates.map { |rc| rc[:date] }
|
||||
).pluck(:base_currency, :converted_currency, :date)
|
||||
).pluck(:from_currency, :to_currency, :date)
|
||||
|
||||
# Convert to a set for faster lookup
|
||||
existing_rates_set = existing_rates.map { |er| [ er[0], er[1], er[2].to_s ] }.to_set
|
||||
|
|
|
@ -7,7 +7,13 @@ module Providable
|
|||
|
||||
class_methods do
|
||||
def exchange_rates_provider
|
||||
Provider::Synth.new
|
||||
api_key = ENV["SYNTH_API_KEY"]
|
||||
|
||||
if api_key.present?
|
||||
Provider::Synth.new api_key
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def git_repository_provider
|
||||
|
|
|
@ -1,29 +1,29 @@
|
|||
class ExchangeRate < ApplicationRecord
|
||||
include Provided
|
||||
|
||||
validates :base_currency, :converted_currency, presence: true
|
||||
validates :from_currency, :to_currency, :date, :rate, presence: true
|
||||
|
||||
class << self
|
||||
def find_rate(from:, to:, date:)
|
||||
find_by \
|
||||
base_currency: Money::Currency.new(from).iso_code,
|
||||
converted_currency: Money::Currency.new(to).iso_code,
|
||||
def find_rate(from:, to:, date:, cache: true)
|
||||
result = find_by \
|
||||
from_currency: from,
|
||||
to_currency: to,
|
||||
date: date
|
||||
|
||||
result || fetch_rate_from_provider(from:, to:, date:, cache:)
|
||||
end
|
||||
|
||||
def find_rate_or_fetch(from:, to:, date:)
|
||||
find_rate(from:, to:, date:) || fetch_rate_from_provider(from:, to:, date:)&.tap(&:save!)
|
||||
end
|
||||
def find_rates(from:, to:, start_date:, end_date: Date.current, cache: true)
|
||||
rates = self.where(from_currency: from, to_currency: to, date: start_date..end_date).to_a
|
||||
all_dates = (start_date..end_date).to_a.to_set
|
||||
existing_dates = rates.map(&:date).to_set
|
||||
missing_dates = all_dates - existing_dates
|
||||
|
||||
def get_rates(from, to, dates)
|
||||
where(base_currency: from, converted_currency: to, date: dates).order(:date)
|
||||
end
|
||||
if missing_dates.any?
|
||||
rates += fetch_rates_from_provider(from:, to:, dates: missing_dates, cache:)
|
||||
end
|
||||
|
||||
def convert(value:, from:, to:, date:)
|
||||
rate = ExchangeRate.find_by(base_currency: from, converted_currency: to, date:)
|
||||
raise "Conversion from: #{from} to: #{to} on: #{date} not found" unless rate
|
||||
|
||||
value * rate.rate
|
||||
rates
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,25 +1,38 @@
|
|||
module ExchangeRate::Provided
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
include Providable
|
||||
|
||||
class_methods do
|
||||
private
|
||||
def fetch_rate_from_provider(from:, to:, date:)
|
||||
return nil unless exchange_rates_provider.configured?
|
||||
|
||||
def fetch_rates_from_provider(from:, to:, dates:, cache: false)
|
||||
return [] unless exchange_rates_provider.present?
|
||||
|
||||
dates.map do |date|
|
||||
fetch_rate_from_provider from:, to:, date:, cache:
|
||||
end.compact
|
||||
end
|
||||
|
||||
def fetch_rate_from_provider(from:, to:, date:, cache: false)
|
||||
return nil unless exchange_rates_provider.present?
|
||||
|
||||
response = exchange_rates_provider.fetch_exchange_rate \
|
||||
from: Money::Currency.new(from).iso_code,
|
||||
to: Money::Currency.new(to).iso_code,
|
||||
from: from,
|
||||
to: to,
|
||||
date: date
|
||||
|
||||
if response.success?
|
||||
ExchangeRate.new \
|
||||
base_currency: from,
|
||||
converted_currency: to,
|
||||
rate = ExchangeRate.new \
|
||||
from_currency: from,
|
||||
to_currency: to,
|
||||
rate: response.rate,
|
||||
date: date
|
||||
|
||||
rate.save! if cache
|
||||
rate
|
||||
else
|
||||
raise response.error
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -97,11 +97,11 @@ class Family < ApplicationRecord
|
|||
end
|
||||
|
||||
def assets
|
||||
Money.new(accounts.active.assets.map { |account| account.balance_money.exchange_to(currency) || 0 }.sum, currency)
|
||||
Money.new(accounts.active.assets.map { |account| account.balance_money.exchange_to(currency, fallback_rate: 0) }.sum, currency)
|
||||
end
|
||||
|
||||
def liabilities
|
||||
Money.new(accounts.active.liabilities.map { |account| account.balance_money.exchange_to(currency) || 0 }.sum, currency)
|
||||
Money.new(accounts.active.liabilities.map { |account| account.balance_money.exchange_to(currency, fallback_rate: 0) }.sum, currency)
|
||||
end
|
||||
|
||||
def sync_accounts
|
||||
|
|
|
@ -1,12 +1,8 @@
|
|||
class Provider::Synth
|
||||
include Retryable
|
||||
|
||||
def initialize(api_key = ENV["SYNTH_API_KEY"])
|
||||
@api_key = api_key || ENV["SYNTH_API_KEY"]
|
||||
end
|
||||
|
||||
def configured?
|
||||
@api_key.present?
|
||||
def initialize(api_key)
|
||||
@api_key = api_key
|
||||
end
|
||||
|
||||
def fetch_exchange_rate(from:, to:, date:)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue