1
0
Fork 0
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:
Zach Gollwitzer 2024-07-08 09:04:59 -04:00 committed by GitHub
parent bef335c631
commit 6767aaed1d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 383 additions and 609 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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