diff --git a/app/components/UI/account_page.html.erb b/app/components/UI/account_page.html.erb index d1c9a5add..b9befa684 100644 --- a/app/components/UI/account_page.html.erb +++ b/app/components/UI/account_page.html.erb @@ -6,6 +6,15 @@ <%= render UI::Account::Chart.new(account: account, period: chart_period, view: chart_view) %> + <% if (fx_coverage_start = fx_coverage_start_date).present? %> +
+ <%= render DS::Alert.new( + message: t("accounts.show.limited_fx_history_warning", date: l(fx_coverage_start, format: :long)), + variant: :warning + ) %> +
+ <% end %> +
<% if tabs.count > 1 %> <%= render DS::Tabs.new(active_tab: active_tab, url_param_key: "tab") do |tabs_container| %> diff --git a/app/components/UI/account_page.rb b/app/components/UI/account_page.rb index 69e77c516..0fc5f0e53 100644 --- a/app/components/UI/account_page.rb +++ b/app/components/UI/account_page.rb @@ -47,6 +47,23 @@ class UI::AccountPage < ApplicationComponent end end + def fx_coverage_start_date + return @fx_coverage_start_date if defined?(@fx_coverage_start_date) + + result = nil + if account.family.present? && account.currency != account.family.currency + pair = ExchangeRatePair.for_pair(from: account.currency, to: account.family.currency) + if pair.first_provider_rate_on.present? + oldest_entry = account.entries.minimum(:date) + if oldest_entry.present? && oldest_entry < pair.first_provider_rate_on + result = pair.first_provider_rate_on + end + end + end + + @fx_coverage_start_date = result + end + def tab_content_for(tab) case tab when :activity diff --git a/app/models/exchange_rate/importer.rb b/app/models/exchange_rate/importer.rb index 247030b6e..bd1533b57 100644 --- a/app/models/exchange_rate/importer.rb +++ b/app/models/exchange_rate/importer.rb @@ -2,6 +2,8 @@ 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 @@ -11,7 +13,6 @@ class ExchangeRate::Importer @clear_cache = clear_cache end - # Constructs a daily series of rates for the given currency pair for date range 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") @@ -26,6 +27,24 @@ class ExchangeRate::Importer 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) @@ -33,20 +52,18 @@ class ExchangeRate::Importer return end - gapfilled_rates = effective_start_date.upto(end_date).map do |date| + # 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 clear_cache - provider_rate_value || db_rate_value # overwrite when possible + 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 - db_rate_value || provider_rate_value # fill gaps - end - - # Gapfill with LOCF strategy (last observation carried forward) - # Treat nil or zero rates as invalid and use previous rate - if chosen_rate.nil? || chosen_rate.to_f <= 0 - chosen_rate = prev_rate_value + prev_rate_value end prev_rate_value = chosen_rate @@ -76,14 +93,27 @@ class ExchangeRate::Importer upsert_rows(inverse_rates) - # Also backfill inverse rows for any forward rates that existed in the DB - # before effective_start_date (i.e. dates not covered by gapfilled_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 @@ -102,34 +132,82 @@ class ExchangeRate::Importer total_upsert_count end - # Since provider may not return values on weekends and holidays, we grab the first rate from the provider that is on or before the start date def start_rate_value - provider_rate_value = provider_rates.select { |date, _| date <= start_date }.max_by { |date, _| date }&.last&.rate - db_rate_value = db_rates[start_date]&.rate - provider_rate_value || db_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 - # No need to fetch/upsert rates for dates that we already have in the DB def effective_start_date return start_date if clear_cache - first_missing_date = nil - - start_date.upto(end_date) do |date| - unless db_rates.key?(date) - first_missing_date = date - break - end - end - - first_missing_date || end_date + (clamped_start_date..end_date).detect { |d| !db_rates.key?(d) } || end_date end def provider_rates @provider_rates ||= begin - # Always fetch with a 5 day buffer to ensure we have a starting rate (for weekends and holidays) - provider_fetch_start_date = effective_start_date - 5.days - provider_response = exchange_rate_provider.fetch_exchange_rates( from: from, to: to, @@ -138,6 +216,7 @@ class ExchangeRate::Importer ) 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}" @@ -148,11 +227,8 @@ class ExchangeRate::Importer end end - # When forward rates already exist but inverse rates are missing (e.g. from a - # deployment before inverse computation was added), backfill them from the DB - # without making any provider API calls. def backfill_inverse_rates_if_needed - existing_inverse_dates = ExchangeRate.where(from_currency: to, to_currency: from, date: start_date..end_date).pluck(:date).to_set + 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| @@ -175,11 +251,13 @@ class ExchangeRate::Importer end def expected_count - (start_date..end_date).count + (clamped_start_date..end_date).count end def db_count - db_rates.count + ExchangeRate + .where(from_currency: from, to_currency: to, date: clamped_start_date..end_date) + .count end def db_rates @@ -190,8 +268,7 @@ class ExchangeRate::Importer end # Normalizes an end date so that it never exceeds today's date in the - # America/New_York timezone. If the caller passes a future date we clamp - # it to today so that upstream provider calls remain valid and predictable. + # 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 diff --git a/app/models/exchange_rate/provided.rb b/app/models/exchange_rate/provided.rb index 46e2a25de..157d793a9 100644 --- a/app/models/exchange_rate/provided.rb +++ b/app/models/exchange_rate/provided.rb @@ -58,6 +58,11 @@ module ExchangeRate::Provided def rates_for(currencies, to:, date: Date.current) currencies.uniq.each_with_object({}) do |currency, map| rate = find_or_fetch_rate(from: currency, to: to, date: date) + if rate.nil? + Rails.logger.warn("No exchange rate found for #{currency}/#{to} on #{date}, using 1") + elsif rate.date != date + Rails.logger.debug("FX rate #{currency}/#{to}: using #{rate.date} for #{date} (gap=#{(date - rate.date).to_i}d)") + end map[currency] = rate&.rate || 1 end end diff --git a/app/models/exchange_rate_pair.rb b/app/models/exchange_rate_pair.rb new file mode 100644 index 000000000..2da5f72be --- /dev/null +++ b/app/models/exchange_rate_pair.rb @@ -0,0 +1,42 @@ +class ExchangeRatePair < ApplicationRecord + validates :from_currency, :to_currency, presence: true + + def self.for_pair(from:, to:, provider_name: nil) + pair = find_or_create_by!(from_currency: from, to_currency: to) + current_provider = provider_name || resolve_provider_name + + if pair.provider_name != current_provider && pair.first_provider_rate_on.present? + ExchangeRatePair + .where(id: pair.id) + .where.not(provider_name: current_provider) + .update_all(first_provider_rate_on: nil, provider_name: current_provider, updated_at: Time.current) + pair.reload + end + + pair + rescue ActiveRecord::RecordNotUnique + find_by!(from_currency: from, to_currency: to) + end + + # Resolves the runtime provider name the same way as ExchangeRate::Provided.provider: + # ENV takes precedence over the DB Setting. + def self.resolve_provider_name + (ENV["EXCHANGE_RATE_PROVIDER"].presence || Setting.exchange_rate_provider).to_s + end + + def self.record_first_provider_rate_on(from:, to:, date:, provider_name: nil) + return if date.blank? + + current_provider = provider_name || resolve_provider_name + pair = for_pair(from: from, to: to, provider_name: current_provider) + + ExchangeRatePair + .where(id: pair.id) + .where("first_provider_rate_on IS NULL OR first_provider_rate_on > ?", date) + .update_all( + first_provider_rate_on: date, + provider_name: current_provider, + updated_at: Time.current + ) + end +end diff --git a/app/models/provider/exchange_rate_concept.rb b/app/models/provider/exchange_rate_concept.rb index 744204a27..dc120ba6a 100644 --- a/app/models/provider/exchange_rate_concept.rb +++ b/app/models/provider/exchange_rate_concept.rb @@ -10,4 +10,12 @@ module Provider::ExchangeRateConcept def fetch_exchange_rates(from:, to:, start_date:, end_date:) raise NotImplementedError, "Subclasses must implement #fetch_exchange_rates" end + + # Maximum number of calendar days of historical FX data the provider can + # return. Returns nil when the provider has no known limit (unbounded). + # Callers should clamp start_date when non-nil to avoid requesting data + # beyond this window. Override in subclasses with provider-specific limits. + def max_history_days + nil + end end diff --git a/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml index 23af6cfab..7a34a0c76 100644 --- a/config/locales/views/accounts/en.yml +++ b/config/locales/views/accounts/en.yml @@ -60,6 +60,7 @@ en: title: How would you like to add it? title: What would you like to add? show: + limited_fx_history_warning: "Exchange rate history is only available from %{date} onwards. Transactions before this date use approximate currency conversions — this can happen when the FX provider only offers a limited historical window." activity: amount: Amount balance: Balance diff --git a/db/migrate/20260412120000_create_exchange_rate_pairs.rb b/db/migrate/20260412120000_create_exchange_rate_pairs.rb new file mode 100644 index 000000000..301020983 --- /dev/null +++ b/db/migrate/20260412120000_create_exchange_rate_pairs.rb @@ -0,0 +1,16 @@ +class CreateExchangeRatePairs < ActiveRecord::Migration[7.2] + def change + create_table :exchange_rate_pairs, id: :uuid, default: -> { "gen_random_uuid()" } do |t| + t.string :from_currency, null: false + t.string :to_currency, null: false + t.date :first_provider_rate_on + t.string :provider_name + t.timestamps + end + + add_index :exchange_rate_pairs, + [ :from_currency, :to_currency ], + unique: true, + name: "index_exchange_rate_pairs_on_pair_unique" + end +end diff --git a/db/schema.rb b/db/schema.rb index 24d6576cb..d81f73e2d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_04_11_082125) do +ActiveRecord::Schema[7.2].define(version: 2026_04_12_120000) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -551,6 +551,16 @@ ActiveRecord::Schema[7.2].define(version: 2026_04_11_082125) do t.index ["tags"], name: "index_eval_samples_on_tags", using: :gin end + create_table "exchange_rate_pairs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "from_currency", null: false + t.string "to_currency", null: false + t.date "first_provider_rate_on" + t.string "provider_name" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["from_currency", "to_currency"], name: "index_exchange_rate_pairs_on_pair_unique", unique: true + end + create_table "exchange_rates", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "from_currency", null: false t.string "to_currency", null: false diff --git a/test/fixtures/exchange_rate_pairs.yml b/test/fixtures/exchange_rate_pairs.yml new file mode 100644 index 000000000..5aa677233 --- /dev/null +++ b/test/fixtures/exchange_rate_pairs.yml @@ -0,0 +1,11 @@ +eur_gbp: + from_currency: EUR + to_currency: GBP + first_provider_rate_on: <%= 10.years.ago.to_date %> + provider_name: twelve_data + +usd_jpy_fresh: + from_currency: USD + to_currency: JPY + first_provider_rate_on: + provider_name: diff --git a/test/models/exchange_rate/importer_test.rb b/test/models/exchange_rate/importer_test.rb index cae3c5015..9de97e51e 100644 --- a/test/models/exchange_rate/importer_test.rb +++ b/test/models/exchange_rate/importer_test.rb @@ -169,6 +169,90 @@ class ExchangeRate::ImporterTest < ActiveSupport::TestCase assert_in_delta (1.0 / 0.85), inverse.rate.to_f, 0.0001 end + test "fresh provider values overwrite stale DB rows within the sync window" do + ExchangeRate.delete_all + + # Day 1: correct, Day 2: missing (gap), Day 3: stale/wrong, Today: missing. + # The gap at day 2 causes effective_start_date = day 2, so the LOCF loop + # covers days 2-4. Day 3's stale value should be overwritten by the + # provider's fresh value (provider wins over DB). + ExchangeRate.create!(from_currency: "USD", to_currency: "EUR", date: 3.days.ago.to_date, rate: 0.86) + ExchangeRate.create!(from_currency: "USD", to_currency: "EUR", date: 1.day.ago.to_date, rate: 0.9253) + + provider_response = provider_success_response([ + OpenStruct.new(from: "USD", to: "EUR", date: 2.days.ago.to_date, rate: 0.87), + OpenStruct.new(from: "USD", to: "EUR", date: 1.day.ago.to_date, rate: 0.88), + OpenStruct.new(from: "USD", to: "EUR", date: Date.current, rate: 0.89) + ]) + + @provider.expects(:fetch_exchange_rates) + .with(from: "USD", to: "EUR", start_date: get_provider_fetch_start_date(2.days.ago.to_date), end_date: Date.current) + .returns(provider_response) + + ExchangeRate::Importer.new( + exchange_rate_provider: @provider, + from: "USD", + to: "EUR", + start_date: 3.days.ago.to_date, + end_date: Date.current + ).import_provider_rates + + db_rates = ExchangeRate.where(from_currency: "USD", to_currency: "EUR").order(:date) + assert_equal 4, db_rates.count + assert_equal [ 0.86, 0.87, 0.88, 0.89 ], db_rates.map(&:rate) + end + + test "backfills missing inverse rates when forward rates already exist" do + ExchangeRate.delete_all + + # Create forward rates without inverses (simulating pre-inverse-computation data) + (2.days.ago.to_date..Date.current).each_with_index do |date, idx| + ExchangeRate.create!(from_currency: "USD", to_currency: "EUR", date: date, rate: 0.85 + idx * 0.01) + end + + # All forward rates exist, so no provider call — but inverse backfill should fire + @provider.expects(:fetch_exchange_rates).never + + ExchangeRate::Importer.new( + exchange_rate_provider: @provider, + from: "USD", + to: "EUR", + start_date: 2.days.ago.to_date, + end_date: Date.current + ).import_provider_rates + + inverse_rates = ExchangeRate.where(from_currency: "EUR", to_currency: "USD").order(:date) + assert_equal 3, inverse_rates.count + + inverse_rates.each do |inv| + forward = ExchangeRate.find_by(from_currency: "USD", to_currency: "EUR", date: inv.date) + assert_in_delta (1.0 / forward.rate.to_f), inv.rate.to_f, 0.0001 + end + end + + test "logs error and imports nothing when provider returns only zero and nil rates" do + ExchangeRate.delete_all + ExchangeRatePair.delete_all + + provider_response = provider_success_response([ + OpenStruct.new(from: "USD", to: "EUR", date: 2.days.ago.to_date, rate: 0), + OpenStruct.new(from: "USD", to: "EUR", date: 1.day.ago.to_date, rate: nil), + OpenStruct.new(from: "USD", to: "EUR", date: Date.current, rate: 0) + ]) + + @provider.expects(:fetch_exchange_rates).returns(provider_response) + + ExchangeRate::Importer.new( + exchange_rate_provider: @provider, + from: "USD", + to: "EUR", + start_date: 2.days.ago.to_date, + end_date: Date.current + ).import_provider_rates + + assert_equal 0, ExchangeRate.where(from_currency: "USD", to_currency: "EUR").count + end + test "handles rate limit error gracefully" do ExchangeRate.delete_all @@ -190,9 +274,222 @@ class ExchangeRate::ImporterTest < ActiveSupport::TestCase assert_equal 0, ExchangeRate.count, "No rates should be imported on rate limit error" end + # === Clamping tests (Phase 2) === + + test "advances gapfill start when pair predates provider history" do + ExchangeRate.delete_all + ExchangeRatePair.delete_all + + # Provider only returns rates starting 5 days ago (simulating limited history). + # start_date is 30 days ago — provider can't serve anything before 5 days ago. + provider_response = provider_success_response([ + OpenStruct.new(from: "USD", to: "EUR", date: 5.days.ago.to_date, rate: 1.1), + OpenStruct.new(from: "USD", to: "EUR", date: 4.days.ago.to_date, rate: 1.2), + OpenStruct.new(from: "USD", to: "EUR", date: 3.days.ago.to_date, rate: 1.3), + OpenStruct.new(from: "USD", to: "EUR", date: 2.days.ago.to_date, rate: 1.4), + OpenStruct.new(from: "USD", to: "EUR", date: 1.day.ago.to_date, rate: 1.5), + OpenStruct.new(from: "USD", to: "EUR", date: Date.current, rate: 1.6) + ]) + + @provider.expects(:fetch_exchange_rates).returns(provider_response) + + ExchangeRate::Importer.new( + exchange_rate_provider: @provider, + from: "USD", + to: "EUR", + start_date: 30.days.ago.to_date, + end_date: Date.current + ).import_provider_rates + + forward_rates = ExchangeRate.where(from_currency: "USD", to_currency: "EUR").order(:date) + assert_equal 6, forward_rates.count + assert_equal 5.days.ago.to_date, forward_rates.first.date + + pair = ExchangeRatePair.find_by(from_currency: "USD", to_currency: "EUR") + assert_equal 5.days.ago.to_date, pair.first_provider_rate_on + end + + test "pre-coverage fallback picks earliest valid provider row, skipping zero leaders" do + ExchangeRate.delete_all + ExchangeRatePair.delete_all + + provider_response = provider_success_response([ + OpenStruct.new(from: "USD", to: "EUR", date: 4.days.ago.to_date, rate: 0), + OpenStruct.new(from: "USD", to: "EUR", date: 3.days.ago.to_date, rate: nil), + OpenStruct.new(from: "USD", to: "EUR", date: 2.days.ago.to_date, rate: 1.3), + OpenStruct.new(from: "USD", to: "EUR", date: 1.day.ago.to_date, rate: 1.4), + OpenStruct.new(from: "USD", to: "EUR", date: Date.current, rate: 1.5) + ]) + + @provider.expects(:fetch_exchange_rates).returns(provider_response) + + ExchangeRate::Importer.new( + exchange_rate_provider: @provider, + from: "USD", + to: "EUR", + start_date: 30.days.ago.to_date, + end_date: Date.current + ).import_provider_rates + + pair = ExchangeRatePair.find_by(from_currency: "USD", to_currency: "EUR") + assert_equal 2.days.ago.to_date, pair.first_provider_rate_on + end + + test "first_provider_rate_on is moved earlier when provider extends backward coverage" do + ExchangeRate.delete_all + ExchangeRatePair.delete_all + + ExchangeRatePair.create!( + from_currency: "USD", to_currency: "EUR", + first_provider_rate_on: 3.days.ago.to_date, + provider_name: Setting.exchange_rate_provider.to_s + ) + + # Provider now returns an earlier date with clear_cache + provider_response = provider_success_response([ + OpenStruct.new(from: "USD", to: "EUR", date: 10.days.ago.to_date, rate: 1.0), + OpenStruct.new(from: "USD", to: "EUR", date: 9.days.ago.to_date, rate: 1.1), + OpenStruct.new(from: "USD", to: "EUR", date: Date.current, rate: 1.5) + ]) + + @provider.expects(:fetch_exchange_rates).returns(provider_response) + + ExchangeRate::Importer.new( + exchange_rate_provider: @provider, + from: "USD", + to: "EUR", + start_date: 30.days.ago.to_date, + end_date: Date.current, + clear_cache: true + ).import_provider_rates + + pair = ExchangeRatePair.find_by!(from_currency: "USD", to_currency: "EUR") + assert_equal 10.days.ago.to_date, pair.first_provider_rate_on + end + + test "first_provider_rate_on is NOT moved forward when provider shrinks coverage" do + ExchangeRate.delete_all + ExchangeRatePair.delete_all + + ExchangeRatePair.create!( + from_currency: "USD", to_currency: "EUR", + first_provider_rate_on: 10.days.ago.to_date, + provider_name: Setting.exchange_rate_provider.to_s + ) + + # Provider now only returns from 3 days ago (shrunk window) + provider_response = provider_success_response([ + OpenStruct.new(from: "USD", to: "EUR", date: 3.days.ago.to_date, rate: 1.3), + OpenStruct.new(from: "USD", to: "EUR", date: Date.current, rate: 1.5) + ]) + + @provider.expects(:fetch_exchange_rates).returns(provider_response) + + ExchangeRate::Importer.new( + exchange_rate_provider: @provider, + from: "USD", + to: "EUR", + start_date: 30.days.ago.to_date, + end_date: Date.current, + clear_cache: true + ).import_provider_rates + + pair = ExchangeRatePair.find_by!(from_currency: "USD", to_currency: "EUR") + assert_equal 10.days.ago.to_date, pair.first_provider_rate_on + end + + test "incremental sync on pre-coverage pair skips pre-coverage window" do + ExchangeRate.delete_all + ExchangeRatePair.delete_all + + clamp_date = 5.days.ago.to_date + ExchangeRatePair.create!( + from_currency: "USD", to_currency: "EUR", + first_provider_rate_on: clamp_date, + provider_name: Setting.exchange_rate_provider.to_s + ) + + # Seed DB with rates from clamp to yesterday + (clamp_date..1.day.ago.to_date).each_with_index do |date, idx| + ExchangeRate.create!(from_currency: "USD", to_currency: "EUR", date: date, rate: 1.0 + idx * 0.01) + end + + # Provider returns today's rate + provider_response = provider_success_response([ + OpenStruct.new(from: "USD", to: "EUR", date: Date.current, rate: 1.5) + ]) + + @provider.expects(:fetch_exchange_rates) + .with(from: "USD", to: "EUR", + start_date: get_provider_fetch_start_date(Date.current), + end_date: Date.current) + .returns(provider_response) + + ExchangeRate::Importer.new( + exchange_rate_provider: @provider, + from: "USD", + to: "EUR", + start_date: 30.days.ago.to_date, + end_date: Date.current + ).import_provider_rates + + assert_equal 1.5, ExchangeRate.find_by(from_currency: "USD", to_currency: "EUR", date: Date.current).rate + end + + test "skips provider call when all rates exist in clamped range" do + ExchangeRate.delete_all + ExchangeRatePair.delete_all + + clamp_date = 3.days.ago.to_date + ExchangeRatePair.create!( + from_currency: "USD", to_currency: "EUR", + first_provider_rate_on: clamp_date, + provider_name: Setting.exchange_rate_provider.to_s + ) + + (clamp_date..Date.current).each_with_index do |date, idx| + ExchangeRate.create!(from_currency: "USD", to_currency: "EUR", date: date, rate: 1.0 + idx * 0.01) + end + + @provider.expects(:fetch_exchange_rates).never + + ExchangeRate::Importer.new( + exchange_rate_provider: @provider, + from: "USD", + to: "EUR", + start_date: 30.days.ago.to_date, + end_date: Date.current + ).import_provider_rates + end + + test "clamps provider fetch to max_history_days when provider exposes limit" do + ExchangeRate.delete_all + ExchangeRatePair.delete_all + + @provider.stubs(:max_history_days).returns(10) + + provider_response = provider_success_response([ + OpenStruct.new(from: "USD", to: "EUR", date: Date.current, rate: 1.5) + ]) + + expected_start = Date.current - 10.days + @provider.expects(:fetch_exchange_rates) + .with(from: "USD", to: "EUR", + start_date: expected_start, + end_date: Date.current) + .returns(provider_response) + + ExchangeRate::Importer.new( + exchange_rate_provider: @provider, + from: "USD", + to: "EUR", + start_date: 60.days.ago.to_date, + end_date: Date.current + ).import_provider_rates + end + private def get_provider_fetch_start_date(start_date) - # We fetch with a 5 day buffer to account for weekends and holidays - start_date - 5.days + start_date - ExchangeRate::Importer::PROVISIONAL_LOOKBACK_DAYS.days end end diff --git a/test/models/exchange_rate_pair_test.rb b/test/models/exchange_rate_pair_test.rb new file mode 100644 index 000000000..7a832fb83 --- /dev/null +++ b/test/models/exchange_rate_pair_test.rb @@ -0,0 +1,78 @@ +require "test_helper" + +class ExchangeRatePairTest < ActiveSupport::TestCase + test "for_pair creates a new pair if none exists" do + ExchangeRatePair.delete_all + pair = ExchangeRatePair.for_pair(from: "USD", to: "EUR") + + assert_equal "USD", pair.from_currency + assert_equal "EUR", pair.to_currency + assert_nil pair.first_provider_rate_on + end + + test "for_pair returns existing pair idempotently" do + ExchangeRatePair.delete_all + pair1 = ExchangeRatePair.for_pair(from: "USD", to: "EUR") + pair2 = ExchangeRatePair.for_pair(from: "USD", to: "EUR") + + assert_equal pair1.id, pair2.id + end + + test "for_pair auto-resets clamp when provider changes" do + ExchangeRatePair.delete_all + + original_provider = Setting.exchange_rate_provider + begin + Setting.exchange_rate_provider = "twelve_data" + ExchangeRatePair.create!( + from_currency: "USD", + to_currency: "EUR", + first_provider_rate_on: 1.year.ago.to_date, + provider_name: "twelve_data" + ) + + Setting.exchange_rate_provider = "yahoo_finance" + refreshed = ExchangeRatePair.for_pair(from: "USD", to: "EUR") + + assert_nil refreshed.first_provider_rate_on + assert_equal "yahoo_finance", refreshed.provider_name + ensure + Setting.exchange_rate_provider = original_provider + end + end + + test "record_first_provider_rate_on sets date on NULL" do + ExchangeRatePair.delete_all + ExchangeRatePair.for_pair(from: "USD", to: "EUR") + + ExchangeRatePair.record_first_provider_rate_on(from: "USD", to: "EUR", date: 6.months.ago.to_date) + + pair = ExchangeRatePair.find_by!(from_currency: "USD", to_currency: "EUR") + assert_equal 6.months.ago.to_date, pair.first_provider_rate_on + end + + test "record_first_provider_rate_on moves earlier but not forward" do + ExchangeRatePair.delete_all + + original_provider = Setting.exchange_rate_provider + begin + Setting.exchange_rate_provider = "twelve_data" + ExchangeRatePair.create!( + from_currency: "USD", + to_currency: "EUR", + first_provider_rate_on: 6.months.ago.to_date, + provider_name: "twelve_data" + ) + + ExchangeRatePair.record_first_provider_rate_on(from: "USD", to: "EUR", date: 1.year.ago.to_date) + pair = ExchangeRatePair.find_by!(from_currency: "USD", to_currency: "EUR") + assert_equal 1.year.ago.to_date, pair.first_provider_rate_on + + ExchangeRatePair.record_first_provider_rate_on(from: "USD", to: "EUR", date: 3.months.ago.to_date) + pair.reload + assert_equal 1.year.ago.to_date, pair.first_provider_rate_on + ensure + Setting.exchange_rate_provider = original_provider + end + end +end