class ExchangeRate::Importer MissingExchangeRateError = Class.new(StandardError) MissingStartRateError = Class.new(StandardError) PROVISIONAL_LOOKBACK_DAYS = 5 def initialize(exchange_rate_provider:, from:, to:, start_date:, end_date:, clear_cache: false) @exchange_rate_provider = exchange_rate_provider @from = from @to = to @start_date = start_date @end_date = normalize_end_date(end_date) @clear_cache = clear_cache end def import_provider_rates if !clear_cache && all_rates_exist? Rails.logger.info("No new rates to sync for #{from} to #{to} between #{start_date} and #{end_date}, skipping") backfill_inverse_rates_if_needed return end if provider_rates.empty? Rails.logger.warn("Could not fetch rates for #{from} to #{to} between #{start_date} and #{end_date} because provider returned no rates") return end prev_rate_value = start_rate_value # Always find the earliest valid provider rate for pair metadata tracking. # record_first_provider_rate_on's atomic guard prevents moving the date forward. earliest_valid_provider_date = provider_rates.values .select { |r| r.rate.present? && r.rate.to_f > 0 } .min_by(&:date)&.date # When no anchor rate exists, advance the loop start to the earliest provider rate loop_start_date = fill_start_date if prev_rate_value.blank? && earliest_valid_provider_date earliest_rate = provider_rates[earliest_valid_provider_date] Rails.logger.info( "#{from}->#{to}: no provider rate on or before #{start_date}; " \ "advancing gapfill start to earliest valid provider date #{earliest_valid_provider_date}" ) prev_rate_value = earliest_rate.rate loop_start_date = earliest_valid_provider_date end unless prev_rate_value.present? error = MissingStartRateError.new("Could not find a start rate for #{from} to #{to} between #{start_date} and #{end_date}") Rails.logger.error(error.message) Sentry.capture_exception(error) return end # Gapfill with LOCF strategy (last observation carried forward): # when the provider returns nothing for weekends/holidays, carry the previous rate. gapfilled_rates = loop_start_date.upto(end_date).map do |date| db_rate_value = db_rates[date]&.rate provider_rate_value = provider_rates[date]&.rate chosen_rate = if provider_rate_value.present? && provider_rate_value.to_f > 0 provider_rate_value elsif db_rate_value.present? && db_rate_value.to_f > 0 db_rate_value else prev_rate_value end prev_rate_value = chosen_rate { from_currency: from, to_currency: to, date: date, rate: chosen_rate } end upsert_rows(gapfilled_rates) # Compute and upsert inverse rates (e.g., EUR→USD from USD→EUR) to avoid # separate API calls for the reverse direction. inverse_rates = gapfilled_rates.filter_map do |row| next if row[:rate].to_f <= 0 { from_currency: row[:to_currency], to_currency: row[:from_currency], date: row[:date], rate: (BigDecimal("1") / BigDecimal(row[:rate].to_s)).round(12) } end upsert_rows(inverse_rates) # Backfill inverse rows for any forward rates that existed in the DB # before the loop range (i.e. dates not covered by gapfilled_rates). backfill_inverse_rates_if_needed if earliest_valid_provider_date.present? ExchangeRatePair.record_first_provider_rate_on( from: from, to: to, date: earliest_valid_provider_date, provider_name: current_provider_name ) end end private attr_reader :exchange_rate_provider, :from, :to, :start_date, :end_date, :clear_cache # Resolves the provider name the same way as ExchangeRate::Provided.provider: # ENV takes precedence over the DB Setting to stay consistent in env-configured deployments. def current_provider_name @current_provider_name ||= (ENV["EXCHANGE_RATE_PROVIDER"].presence || Setting.exchange_rate_provider).to_s end def upsert_rows(rows) batch_size = 200 total_upsert_count = 0 rows.each_slice(batch_size) do |batch| upserted_ids = ExchangeRate.upsert_all( batch, unique_by: %i[from_currency to_currency date], returning: [ "id" ] ) total_upsert_count += upserted_ids.count end total_upsert_count end def start_rate_value if fill_start_date == start_date provider_rate_value = latest_valid_provider_rate(before_or_on: start_date) db_rate_value = db_rates[start_date]&.rate return provider_rate_value if provider_rate_value.present? return db_rate_value if db_rate_value.present? && db_rate_value.to_f > 0 return nil end cutoff_date = fill_start_date provider_rate_value = latest_valid_provider_rate(before: cutoff_date) return provider_rate_value if provider_rate_value.present? ExchangeRate .where(from_currency: from, to_currency: to) .where("date < ?", cutoff_date) .where("rate > 0") .order(date: :desc) .limit(1) .pick(:rate) end # Scans provider_rates for the most recent entry with a positive rate, # rather than just picking the latest row (which could be zero/nil). def latest_valid_provider_rate(before_or_on: nil, before: nil) cutoff = before_or_on || before comparator = before_or_on ? :<= : :< provider_rates .select { |date, r| date.send(comparator, cutoff) && r.rate.present? && r.rate.to_f > 0 } .max_by { |date, _| date }&.last&.rate end def clamped_start_date @clamped_start_date ||= begin listed = exchange_rate_pair.first_provider_rate_on listed.present? && listed > start_date ? listed : start_date end end def exchange_rate_pair @exchange_rate_pair ||= ExchangeRatePair.for_pair(from: from, to: to, provider_name: current_provider_name) end def fill_start_date @fill_start_date ||= [ provider_fetch_start_date, effective_start_date ].max end def provider_fetch_start_date @provider_fetch_start_date ||= begin base = effective_start_date - PROVISIONAL_LOOKBACK_DAYS.days max_days = exchange_rate_provider.respond_to?(:max_history_days) ? exchange_rate_provider.max_history_days : nil if max_days && (end_date - base).to_i > max_days clamped = end_date - max_days.days Rails.logger.info( "#{exchange_rate_provider.class.name} max history is #{max_days} days; " \ "clamping #{from}->#{to} start_date from #{base} to #{clamped}" ) clamped else base end end end def effective_start_date return start_date if clear_cache (clamped_start_date..end_date).detect { |d| !db_rates.key?(d) } || end_date end def provider_rates @provider_rates ||= begin provider_response = exchange_rate_provider.fetch_exchange_rates( from: from, to: to, start_date: provider_fetch_start_date, end_date: end_date ) if provider_response.success? Rails.logger.debug("Fetched #{provider_response.data.size} rates from #{exchange_rate_provider.class.name} for #{from}/#{to} between #{provider_fetch_start_date} and #{end_date}") provider_response.data.index_by(&:date) else message = "#{exchange_rate_provider.class.name} could not fetch exchange rate pair from: #{from} to: #{to} between: #{effective_start_date} and: #{Date.current}. Provider error: #{provider_response.error.message}" Rails.logger.warn(message) Sentry.capture_exception(MissingExchangeRateError.new(message), level: :warning) {} end end end def backfill_inverse_rates_if_needed existing_inverse_dates = ExchangeRate.where(from_currency: to, to_currency: from, date: clamped_start_date..end_date).pluck(:date).to_set return if existing_inverse_dates.size >= expected_count inverse_rows = db_rates.filter_map do |_date, rate| next if existing_inverse_dates.include?(rate.date) next if rate.rate.to_f <= 0 { from_currency: to, to_currency: from, date: rate.date, rate: (BigDecimal("1") / BigDecimal(rate.rate.to_s)).round(12) } end upsert_rows(inverse_rows) if inverse_rows.any? end def all_rates_exist? db_count == expected_count end def expected_count (clamped_start_date..end_date).count end def db_count ExchangeRate .where(from_currency: from, to_currency: to, date: clamped_start_date..end_date) .count end def db_rates @db_rates ||= ExchangeRate.where(from_currency: from, to_currency: to, date: start_date..end_date) .order(:date) .to_a .index_by(&:date) end # Normalizes an end date so that it never exceeds today's date in the # America/New_York timezone. def normalize_end_date(requested_end_date) today_est = Date.current.in_time_zone("America/New_York").to_date [ requested_end_date, today_est ].min end end