mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-22 06:39:39 +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:
parent
a1b25f1c5b
commit
7ae25dd6df
16 changed files with 310 additions and 34 deletions
3
Gemfile
3
Gemfile
|
@ -54,4 +54,7 @@ end
|
|||
group :test do
|
||||
gem "capybara"
|
||||
gem "selenium-webdriver"
|
||||
gem "mocha"
|
||||
gem "vcr"
|
||||
gem "webmock"
|
||||
end
|
||||
|
|
15
Gemfile.lock
15
Gemfile.lock
|
@ -134,6 +134,9 @@ GEM
|
|||
xpath (~> 3.2)
|
||||
concurrent-ruby (1.2.3)
|
||||
connection_pool (2.4.1)
|
||||
crack (1.0.0)
|
||||
bigdecimal
|
||||
rexml
|
||||
crass (1.0.6)
|
||||
date (3.3.4)
|
||||
debug (1.9.1)
|
||||
|
@ -164,6 +167,7 @@ GEM
|
|||
fugit (>= 1.1)
|
||||
railties (>= 6.0.0)
|
||||
thor (>= 0.14.1)
|
||||
hashdiff (1.1.0)
|
||||
highline (3.0.1)
|
||||
hotwire-livereload (1.3.1)
|
||||
actioncable (>= 6.0.0)
|
||||
|
@ -214,6 +218,8 @@ GEM
|
|||
matrix (0.4.2)
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.21.2)
|
||||
mocha (2.1.0)
|
||||
ruby2_keywords (>= 0.0.5)
|
||||
msgpack (1.7.2)
|
||||
net-http (0.4.1)
|
||||
uri
|
||||
|
@ -335,6 +341,7 @@ GEM
|
|||
ruby-lsp (>= 0.14.2, < 0.15.0)
|
||||
sorbet-runtime (>= 0.5.9897)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.3.2)
|
||||
selenium-webdriver (4.18.1)
|
||||
base64 (~> 0.2)
|
||||
|
@ -371,11 +378,16 @@ GEM
|
|||
unicode-display_width (2.5.0)
|
||||
uri (0.13.0)
|
||||
useragent (0.16.10)
|
||||
vcr (6.2.0)
|
||||
web-console (4.2.1)
|
||||
actionview (>= 6.0.0)
|
||||
activemodel (>= 6.0.0)
|
||||
bindex (>= 0.4.0)
|
||||
railties (>= 6.0.0)
|
||||
webmock (3.23.0)
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
webrick (1.8.1)
|
||||
websocket (1.2.10)
|
||||
websocket-driver (0.7.6)
|
||||
|
@ -408,6 +420,7 @@ DEPENDENCIES
|
|||
inline_svg
|
||||
letter_opener
|
||||
lucide-rails!
|
||||
mocha
|
||||
pagy
|
||||
pg (~> 1.5)
|
||||
propshaft
|
||||
|
@ -422,7 +435,9 @@ DEPENDENCIES
|
|||
tailwindcss-rails
|
||||
turbo-rails
|
||||
tzinfo-data
|
||||
vcr
|
||||
web-console
|
||||
webmock
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.3.0p0
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
13
app/models/concerns/providable.rb
Normal file
13
app/models/concerns/providable.rb
Normal 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
|
|
@ -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
|
||||
|
|
24
app/models/exchange_rate/provided.rb
Normal file
24
app/models/exchange_rate/provided.rb
Normal 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
|
18
app/models/provider/base.rb
Normal file
18
app/models/provider/base.rb
Normal 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
|
51
app/models/provider/synth.rb
Normal file
51
app/models/provider/synth.rb
Normal 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
|
|
@ -26,7 +26,7 @@ class Money
|
|||
# TODO: Replace with injected rate store
|
||||
def exchange_to(other_currency, date = Date.current)
|
||||
return self if @currency == Money::Currency.new(other_currency)
|
||||
rate = ExchangeRate.get_rate(@currency, other_currency, date)
|
||||
rate = ExchangeRate.find_rate(from: @currency, to: other_currency, date: date)
|
||||
return nil if rate.nil?
|
||||
Money.new(@amount * rate.rate, other_currency)
|
||||
end
|
||||
|
|
19
lib/retryable.rb
Normal file
19
lib/retryable.rb
Normal file
|
@ -0,0 +1,19 @@
|
|||
module Retryable
|
||||
def retrying(retryable_errors = [], max_retries: 3)
|
||||
attempts = 0
|
||||
|
||||
begin
|
||||
on_last_attempt = attempts == max_retries - 1
|
||||
|
||||
yield on_last_attempt
|
||||
rescue *retryable_errors => e
|
||||
attempts += 1
|
||||
|
||||
if attempts < max_retries
|
||||
retry
|
||||
else
|
||||
raise e
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
27
test/interfaces/exchange_rate_provider_interface_test.rb
Normal file
27
test/interfaces/exchange_rate_provider_interface_test.rb
Normal file
|
@ -0,0 +1,27 @@
|
|||
require "test_helper"
|
||||
|
||||
module ExchangeRateProviderInterfaceTest
|
||||
extend ActiveSupport::Testing::Declarative
|
||||
|
||||
test "exchange rate provider interface" do
|
||||
assert_respond_to @subject, :fetch_exchange_rate
|
||||
end
|
||||
|
||||
test "exchange rate provider response contract" do
|
||||
accounting_for_http_calls do
|
||||
response = @subject.fetch_exchange_rate from: "USD", to: "MXN", date: Date.current
|
||||
|
||||
assert_respond_to response, :rate
|
||||
assert_respond_to response, :success?
|
||||
assert_respond_to response, :error
|
||||
assert_respond_to response, :raw_response
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def accounting_for_http_calls
|
||||
VCR.use_cassette "synth_exchange_rate" do
|
||||
yield
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,7 +1,42 @@
|
|||
require "test_helper"
|
||||
|
||||
class ExchangeRateTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
test "find rate in db" do
|
||||
assert_equal exchange_rates(:day_29_ago_eur_to_usd),
|
||||
ExchangeRate.find_rate_or_fetch(from: "EUR", to: "USD", date: 29.days.ago.to_date)
|
||||
end
|
||||
|
||||
test "fetch rate from provider when it's not found in db" do
|
||||
ExchangeRate
|
||||
.expects(:fetch_rate_from_provider)
|
||||
.returns(ExchangeRate.new(base_currency: "USD", converted_currency: "MXN", rate: 1.0, date: Date.current))
|
||||
|
||||
ExchangeRate.find_rate_or_fetch from: "USD", to: "MXN", date: Date.current
|
||||
end
|
||||
|
||||
test "provided rates are saved to the db" do
|
||||
VCR.use_cassette "synth_exchange_rate" do
|
||||
assert_difference "ExchangeRate.count", 1 do
|
||||
ExchangeRate.find_rate_or_fetch from: "USD", to: "MXN", date: Date.current
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "retrying, then raising on provider error" do
|
||||
Faraday.expects(:get).returns(OpenStruct.new(success?: false)).times(3)
|
||||
|
||||
error = assert_raises Provider::Base::ProviderError do
|
||||
ExchangeRate.find_rate_or_fetch from: "USD", to: "MXN", date: Date.current
|
||||
end
|
||||
|
||||
assert_match "Failed to fetch exchange rate from Provider::Synth", error.message
|
||||
end
|
||||
|
||||
test "retrying, then raising on network error" do
|
||||
Faraday.expects(:get).raises(Faraday::TimeoutError).times(3)
|
||||
|
||||
assert_raises Faraday::TimeoutError do
|
||||
ExchangeRate.find_rate_or_fetch from: "USD", to: "MXN", date: Date.current
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
9
test/models/provider/synth_test.rb
Normal file
9
test/models/provider/synth_test.rb
Normal file
|
@ -0,0 +1,9 @@
|
|||
require "test_helper"
|
||||
|
||||
class Provider::SynthTest < ActiveSupport::TestCase
|
||||
include ExchangeRateProviderInterfaceTest
|
||||
|
||||
setup do
|
||||
@subject = Provider::Synth.new
|
||||
end
|
||||
end
|
|
@ -1,6 +1,17 @@
|
|||
ENV["RAILS_ENV"] ||= "test"
|
||||
require_relative "../config/environment"
|
||||
require "rails/test_help"
|
||||
require "minitest/mock"
|
||||
require "minitest/autorun"
|
||||
require "mocha/minitest"
|
||||
|
||||
VCR.configure do |config|
|
||||
config.cassette_library_dir = "test/vcr_cassettes"
|
||||
config.hook_into :webmock
|
||||
config.ignore_localhost = true
|
||||
config.default_cassette_options = { erb: true }
|
||||
config.filter_sensitive_data("<SYNTH_API_KEY>") { ENV["SYNTH_API_KEY"] }
|
||||
end
|
||||
|
||||
module ActiveSupport
|
||||
class TestCase
|
||||
|
@ -16,3 +27,5 @@ module ActiveSupport
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
Dir[Rails.root.join("test", "interfaces", "**", "*.rb")].each { |f| require f }
|
||||
|
|
69
test/vcr_cassettes/synth_exchange_rate.yml
Normal file
69
test/vcr_cassettes/synth_exchange_rate.yml
Normal file
|
@ -0,0 +1,69 @@
|
|||
---
|
||||
http_interactions:
|
||||
- request:
|
||||
method: get
|
||||
uri: https://api.synthfinance.com/rates/historical?date=<%= Date.current.to_s %>&from=USD&to=MXN
|
||||
body:
|
||||
encoding: US-ASCII
|
||||
string: ''
|
||||
headers:
|
||||
User-Agent:
|
||||
- Faraday v2.9.0
|
||||
Authorization:
|
||||
- Bearer <SYNTH_API_KEY>
|
||||
Accept-Encoding:
|
||||
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
|
||||
Accept:
|
||||
- "*/*"
|
||||
response:
|
||||
status:
|
||||
code: 200
|
||||
message: OK
|
||||
headers:
|
||||
Date:
|
||||
- Wed, 27 Mar 2024 02:54:11 GMT
|
||||
Content-Type:
|
||||
- application/json; charset=utf-8
|
||||
Content-Length:
|
||||
- '138'
|
||||
Connection:
|
||||
- keep-alive
|
||||
Cf-Ray:
|
||||
- 86ac182ad9ec7ce5-LAX
|
||||
Cf-Cache-Status:
|
||||
- DYNAMIC
|
||||
Cache-Control:
|
||||
- max-age=0, private, must-revalidate
|
||||
Etag:
|
||||
- W/"46780d3f34043bb3bc799b1efae62418"
|
||||
Strict-Transport-Security:
|
||||
- max-age=63072000; includeSubDomains
|
||||
Vary:
|
||||
- Accept-Encoding
|
||||
Referrer-Policy:
|
||||
- strict-origin-when-cross-origin
|
||||
Rndr-Id:
|
||||
- 3ca97b82-f963-43a3
|
||||
X-Content-Type-Options:
|
||||
- nosniff
|
||||
X-Frame-Options:
|
||||
- SAMEORIGIN
|
||||
X-Permitted-Cross-Domain-Policies:
|
||||
- none
|
||||
X-Render-Origin-Server:
|
||||
- Render
|
||||
X-Request-Id:
|
||||
- 64731a8c-4cad-4e42-81c9-60b0d3634a0f
|
||||
X-Runtime:
|
||||
- '0.021432'
|
||||
X-Xss-Protection:
|
||||
- '0'
|
||||
Server:
|
||||
- cloudflare
|
||||
Alt-Svc:
|
||||
- h3=":443"; ma=86400
|
||||
body:
|
||||
encoding: ASCII-8BIT
|
||||
string: '{"data":{"date":"<%= Date.current.to_s %>","source":"USD","rates":{"MXN":16.64663}},"meta":{"total_records":1,"credits_used":1,"credits_remaining":976}}'
|
||||
recorded_at: Wed, 27 Mar 2024 02:54:11 GMT
|
||||
recorded_with: VCR 6.2.0
|
Loading…
Add table
Add a link
Reference in a new issue