diff --git a/Gemfile b/Gemfile index f7bf9115..6dca1383 100644 --- a/Gemfile +++ b/Gemfile @@ -54,4 +54,7 @@ end group :test do gem "capybara" gem "selenium-webdriver" + gem "mocha" + gem "vcr" + gem "webmock" end diff --git a/Gemfile.lock b/Gemfile.lock index 57ff0c91..5853fbd6 100644 --- a/Gemfile.lock +++ b/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 diff --git a/app/models/account/syncable.rb b/app/models/account/syncable.rb index 5e4d3780..5b47cfd7 100644 --- a/app/models/account/syncable.rb +++ b/app/models/account/syncable.rb @@ -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 diff --git a/app/models/concerns/.keep b/app/models/concerns/.keep deleted file mode 100644 index e69de29b..00000000 diff --git a/app/models/concerns/providable.rb b/app/models/concerns/providable.rb new file mode 100644 index 00000000..24f82402 --- /dev/null +++ b/app/models/concerns/providable.rb @@ -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 diff --git a/app/models/exchange_rate.rb b/app/models/exchange_rate.rb index 3a5aee8c..9f4d04fc 100644 --- a/app/models/exchange_rate.rb +++ b/app/models/exchange_rate.rb @@ -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 diff --git a/app/models/exchange_rate/provided.rb b/app/models/exchange_rate/provided.rb new file mode 100644 index 00000000..afbb644e --- /dev/null +++ b/app/models/exchange_rate/provided.rb @@ -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 diff --git a/app/models/provider/base.rb b/app/models/provider/base.rb new file mode 100644 index 00000000..dcf438e5 --- /dev/null +++ b/app/models/provider/base.rb @@ -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 diff --git a/app/models/provider/synth.rb b/app/models/provider/synth.rb new file mode 100644 index 00000000..6426d580 --- /dev/null +++ b/app/models/provider/synth.rb @@ -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 diff --git a/lib/money.rb b/lib/money.rb index c09ef128..cee14dc8 100644 --- a/lib/money.rb +++ b/lib/money.rb @@ -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 diff --git a/lib/retryable.rb b/lib/retryable.rb new file mode 100644 index 00000000..2f1bd002 --- /dev/null +++ b/lib/retryable.rb @@ -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 diff --git a/test/interfaces/exchange_rate_provider_interface_test.rb b/test/interfaces/exchange_rate_provider_interface_test.rb new file mode 100644 index 00000000..ae288641 --- /dev/null +++ b/test/interfaces/exchange_rate_provider_interface_test.rb @@ -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 diff --git a/test/models/exchange_rate_test.rb b/test/models/exchange_rate_test.rb index 23b5458a..99bbb997 100644 --- a/test/models/exchange_rate_test.rb +++ b/test/models/exchange_rate_test.rb @@ -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 diff --git a/test/models/provider/synth_test.rb b/test/models/provider/synth_test.rb new file mode 100644 index 00000000..74f98f07 --- /dev/null +++ b/test/models/provider/synth_test.rb @@ -0,0 +1,9 @@ +require "test_helper" + +class Provider::SynthTest < ActiveSupport::TestCase + include ExchangeRateProviderInterfaceTest + + setup do + @subject = Provider::Synth.new + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 118dc97e..3a9e1f50 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -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("") { 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 } diff --git a/test/vcr_cassettes/synth_exchange_rate.yml b/test/vcr_cassettes/synth_exchange_rate.yml new file mode 100644 index 00000000..314ca123 --- /dev/null +++ b/test/vcr_cassettes/synth_exchange_rate.yml @@ -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 + 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