1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-05 05:25:24 +02:00

Implement Synth as an exchange rate provider (#574)

* Implement Synth as an exchange rate provider

* Add assertions to provider interface test

* Assert the correct provider error is raised

* Remove unnecessary parens
This commit is contained in:
Jose Farias 2024-03-27 09:16:00 -06:00 committed by GitHub
parent a1b25f1c5b
commit 7ae25dd6df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 310 additions and 34 deletions

View file

@ -62,7 +62,7 @@ module Account::Syncable
next if existing_rates_set.include?([ rc_from, rc_to, rc_date.to_s ])
logger.info "Fetching exchange rate from provider for account #{self.name}: #{self.id} (#{rc_from} to #{rc_to} on #{rc_date})"
rate = ExchangeRate.fetch_rate_from_provider(rc_from, rc_to, rc_date)
rate = ExchangeRate.find_rate_or_fetch from: rc_from, to: rc_to, date: rc_date
ExchangeRate.create! base_currency: rc_from, converted_currency: rc_to, date: rc_date, rate: rate if rate
end

View file

@ -0,0 +1,13 @@
# `Providable` serves as an extension point for integrating multiple providers.
# For an example of a multi-provider, multi-concept implementation,
# see: https://github.com/maybe-finance/maybe/pull/561
module Providable
extend ActiveSupport::Concern
class_methods do
def exchange_rates_provider
Provider::Synth.new
end
end
end

View file

@ -1,42 +1,22 @@
class ExchangeRate < ApplicationRecord
include Provided
validates :base_currency, :converted_currency, presence: true
class << self
def convert(from, to, amount)
rate = ExchangeRate.find_by(base_currency: from, converted_currency: to, date: Date.current)
return nil if rate.nil?
amount * rate.rate
def find_rate(from:, to:, date:)
find_by \
base_currency: Money::Currency.new(from).iso_code,
converted_currency: Money::Currency.new(to).iso_code,
date: date
end
def get_rate(from, to, date)
_from = Money::Currency.new(from)
_to = Money::Currency.new(to)
find_by! base_currency: _from.iso_code, converted_currency: _to.iso_code, date: date
rescue
logger.warn "Exchange rate not found for #{_from.iso_code} to #{_to.iso_code} on #{date}"
nil
def find_rate_or_fetch(from:, to:, date:)
find_rate(from:, to:, date:) || fetch_rate_from_provider(from:, to:, date:).tap(&:save!)
end
def get_rate_series(from, to, date_range)
where(base_currency: from, converted_currency: to, date: date_range).order(:date)
end
# TODO: Replace with generic provider
# See https://github.com/maybe-finance/maybe/pull/556
def fetch_rate_from_provider(from, to, date)
response = Faraday.get("https://api.synthfinance.com/rates/historical") do |req|
req.headers["Authorization"] = "Bearer #{ENV["SYNTH_API_KEY"]}"
req.params["date"] = date.to_s
req.params["from"] = from
req.params["to"] = to
end
if response.success?
rates = JSON.parse(response.body)
rates.dig("data", "rates", to)
else
nil
end
end
end
end

View file

@ -0,0 +1,24 @@
module ExchangeRate::Provided
extend ActiveSupport::Concern
include Providable
class_methods do
private
def fetch_rate_from_provider(from:, to:, date:)
response = exchange_rates_provider.fetch_exchange_rate \
from: Money::Currency.new(from).iso_code,
to: Money::Currency.new(to).iso_code,
date: date
if response.success?
ExchangeRate.new \
base_currency: from,
converted_currency: to,
rate: response.rate,
date: date
else
raise response.error
end
end
end
end

View file

@ -0,0 +1,18 @@
class Provider::Base
ProviderError = Class.new(StandardError)
TRANSIENT_NETWORK_ERRORS = [
Faraday::TimeoutError,
Faraday::ConnectionFailed,
Faraday::SSLError,
Faraday::ClientError,
Faraday::ServerError
]
class << self
def known_transient_errors
TRANSIENT_NETWORK_ERRORS + [ ProviderError ]
end
end
end

View file

@ -0,0 +1,51 @@
class Provider::Synth
include Retryable
def initialize(api_key = ENV["SYNTH_API_KEY"])
@api_key = api_key || ENV["SYNTH_API_KEY"]
end
def fetch_exchange_rate(from:, to:, date:)
retrying Provider::Base.known_transient_errors do |on_last_attempt|
response = Faraday.get("#{base_url}/rates/historical") do |req|
req.headers["Authorization"] = "Bearer #{api_key}"
req.params["date"] = date.to_s
req.params["from"] = from
req.params["to"] = to
end
if response.success?
ExchangeRateResponse.new \
rate: JSON.parse(response.body).dig("data", "rates", to),
success?: true,
raw_response: response
else
if on_last_attempt
ExchangeRateResponse.new \
success?: false,
error: build_error(response),
raw_response: response
else
raise build_error(response)
end
end
end
end
private
attr_reader :api_key
ExchangeRateResponse = Struct.new :rate, :success?, :error, :raw_response, keyword_init: true
def base_url
"https://api.synthfinance.com"
end
def build_error(response)
Provider::Base::ProviderError.new(<<~ERROR)
Failed to fetch exchange rate from #{self.class}
Status: #{response.status}
Body: #{response.body.inspect}
ERROR
end
end