diff --git a/app/models/account.rb b/app/models/account.rb index b7b11692..5440ad83 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -28,6 +28,51 @@ class Account < ApplicationRecord delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy + class << self + def by_group(period: Period.all, currency: Money.default_currency) + grouped_accounts = { assets: ValueGroup.new("Assets", currency), liabilities: ValueGroup.new("Liabilities", currency) } + + Accountable.by_classification.each do |classification, types| + types.each do |type| + group = grouped_accounts[classification.to_sym].add_child_group(type, currency) + self.where(accountable_type: type).each do |account| + group.add_value_node( + account, + account.balance_money.exchange_to(currency, fallback_rate: 0), + account.series(period: period, currency: currency) + ) + end + end + end + + grouped_accounts + end + + def create_with_optional_start_balance!(attributes:, start_date: nil, start_balance: nil) + account = self.new(attributes.except(:accountable_type)) + account.accountable = Accountable.from_type(attributes[:accountable_type])&.new + + # Always build the initial valuation + account.entries.build \ + date: Date.current, + amount: attributes[:balance], + currency: account.currency, + entryable: Account::Valuation.new + + # Conditionally build the optional start valuation + if start_date.present? && start_balance.present? + account.entries.build \ + date: start_date, + amount: start_balance, + currency: account.currency, + entryable: Account::Valuation.new + end + + account.save! + account + end + end + def balance_on(date) balances.where("date <= ?", date).order(date: :desc).first&.balance end @@ -50,57 +95,11 @@ class Account < ApplicationRecord balance_series = balances.in_period(period).where(currency: Money::Currency.new(currency).iso_code) if balance_series.empty? && period.date_range.end == Date.current - converted_balance = balance_money.exchange_to(currency) - if converted_balance - TimeSeries.new([ { date: Date.current, value: converted_balance } ]) - else - TimeSeries.new([]) - end + TimeSeries.new([ { date: Date.current, value: balance_money.exchange_to(currency) } ]) else TimeSeries.from_collection(balance_series, :balance_money) end - end - - def self.by_group(period: Period.all, currency: Money.default_currency) - grouped_accounts = { assets: ValueGroup.new("Assets", currency), liabilities: ValueGroup.new("Liabilities", currency) } - - Accountable.by_classification.each do |classification, types| - types.each do |type| - group = grouped_accounts[classification.to_sym].add_child_group(type, currency) - self.where(accountable_type: type).each do |account| - value_node = group.add_value_node( - account, - account.balance_money.exchange_to(currency) || Money.new(0, currency), - account.series(period: period, currency: currency) - ) - end - end - end - - grouped_accounts - end - - def self.create_with_optional_start_balance!(attributes:, start_date: nil, start_balance: nil) - account = self.new(attributes.except(:accountable_type)) - account.accountable = Accountable.from_type(attributes[:accountable_type])&.new - - # Always build the initial valuation - account.entries.build \ - date: Date.current, - amount: attributes[:balance], - currency: account.currency, - entryable: Account::Valuation.new - - # Conditionally build the optional start valuation - if start_date.present? && start_balance.present? - account.entries.build \ - date: start_date, - amount: start_balance, - currency: account.currency, - entryable: Account::Valuation.new - end - - account.save! - account + rescue Money::ConversionError + TimeSeries.new([]) end end diff --git a/app/models/account/balance/calculator.rb b/app/models/account/balance/calculator.rb index 223418e4..8b30d61f 100644 --- a/app/models/account/balance/calculator.rb +++ b/app/models/account/balance/calculator.rb @@ -72,10 +72,10 @@ class Account::Balance::Calculator end def convert_balances_to_family_currency(balances) - rates = ExchangeRate.get_rates( - account.currency, - account.family.currency, - calc_start_date..Date.current + rates = ExchangeRate.find_rates( + from: account.currency, + to: account.family.currency, + start_date: calc_start_date ).to_a # Abort conversion if some required rates are missing @@ -84,8 +84,9 @@ class Account::Balance::Calculator return [] end - balances.map.with_index do |balance, index| - converted_balance = balance[:balance] * rates[index].rate + balances.map do |balance| + rate = rates.find { |r| r.date == balance[:date] } + converted_balance = balance[:balance] * rate&.rate { date: balance[:date], balance: converted_balance, currency: account.family.currency, updated_at: Time.current } end end diff --git a/app/models/account/entry.rb b/app/models/account/entry.rb index 0b14ee6f..78487e5e 100644 --- a/app/models/account/entry.rb +++ b/app/models/account/entry.rb @@ -22,7 +22,7 @@ class Account::Entry < ApplicationRecord "account_entries.*", "account_entries.amount * COALESCE(er.rate, 1) AS converted_amount" ) - .joins(sanitize_sql_array([ "LEFT JOIN exchange_rates er ON account_entries.date = er.date AND account_entries.currency = er.base_currency AND er.converted_currency = ?", currency ])) + .joins(sanitize_sql_array([ "LEFT JOIN exchange_rates er ON account_entries.date = er.date AND account_entries.currency = er.from_currency AND er.to_currency = ?", currency ])) .where("er.rate IS NOT NULL OR account_entries.currency = ?", currency) } diff --git a/app/models/account/syncable.rb b/app/models/account/syncable.rb index a3e09ef9..31ed1e7e 100644 --- a/app/models/account/syncable.rb +++ b/app/models/account/syncable.rb @@ -65,10 +65,10 @@ module Account::Syncable return if rate_candidates.blank? existing_rates = ExchangeRate.where( - base_currency: rate_candidates.map { |rc| rc[:from_currency] }, - converted_currency: rate_candidates.map { |rc| rc[:to_currency] }, + from_currency: rate_candidates.map { |rc| rc[:from_currency] }, + to_currency: rate_candidates.map { |rc| rc[:to_currency] }, date: rate_candidates.map { |rc| rc[:date] } - ).pluck(:base_currency, :converted_currency, :date) + ).pluck(:from_currency, :to_currency, :date) # Convert to a set for faster lookup existing_rates_set = existing_rates.map { |er| [ er[0], er[1], er[2].to_s ] }.to_set diff --git a/app/models/concerns/providable.rb b/app/models/concerns/providable.rb index 9b80bbf8..3f3b1aa7 100644 --- a/app/models/concerns/providable.rb +++ b/app/models/concerns/providable.rb @@ -7,7 +7,13 @@ module Providable class_methods do def exchange_rates_provider - Provider::Synth.new + api_key = ENV["SYNTH_API_KEY"] + + if api_key.present? + Provider::Synth.new api_key + else + nil + end end def git_repository_provider diff --git a/app/models/exchange_rate.rb b/app/models/exchange_rate.rb index b3260b55..eb17a03a 100644 --- a/app/models/exchange_rate.rb +++ b/app/models/exchange_rate.rb @@ -1,29 +1,29 @@ class ExchangeRate < ApplicationRecord include Provided - validates :base_currency, :converted_currency, presence: true + validates :from_currency, :to_currency, :date, :rate, presence: true class << self - def find_rate(from:, to:, date:) - find_by \ - base_currency: Money::Currency.new(from).iso_code, - converted_currency: Money::Currency.new(to).iso_code, + def find_rate(from:, to:, date:, cache: true) + result = find_by \ + from_currency: from, + to_currency: to, date: date + + result || fetch_rate_from_provider(from:, to:, date:, cache:) end - def find_rate_or_fetch(from:, to:, date:) - find_rate(from:, to:, date:) || fetch_rate_from_provider(from:, to:, date:)&.tap(&:save!) - end + def find_rates(from:, to:, start_date:, end_date: Date.current, cache: true) + rates = self.where(from_currency: from, to_currency: to, date: start_date..end_date).to_a + all_dates = (start_date..end_date).to_a.to_set + existing_dates = rates.map(&:date).to_set + missing_dates = all_dates - existing_dates - def get_rates(from, to, dates) - where(base_currency: from, converted_currency: to, date: dates).order(:date) - end + if missing_dates.any? + rates += fetch_rates_from_provider(from:, to:, dates: missing_dates, cache:) + end - def convert(value:, from:, to:, date:) - rate = ExchangeRate.find_by(base_currency: from, converted_currency: to, date:) - raise "Conversion from: #{from} to: #{to} on: #{date} not found" unless rate - - value * rate.rate + rates end end end diff --git a/app/models/exchange_rate/provided.rb b/app/models/exchange_rate/provided.rb index 8da089d7..a0e0a580 100644 --- a/app/models/exchange_rate/provided.rb +++ b/app/models/exchange_rate/provided.rb @@ -1,25 +1,38 @@ module ExchangeRate::Provided extend ActiveSupport::Concern + include Providable class_methods do private - def fetch_rate_from_provider(from:, to:, date:) - return nil unless exchange_rates_provider.configured? + + def fetch_rates_from_provider(from:, to:, dates:, cache: false) + return [] unless exchange_rates_provider.present? + + dates.map do |date| + fetch_rate_from_provider from:, to:, date:, cache: + end.compact + end + + def fetch_rate_from_provider(from:, to:, date:, cache: false) + return nil unless exchange_rates_provider.present? response = exchange_rates_provider.fetch_exchange_rate \ - from: Money::Currency.new(from).iso_code, - to: Money::Currency.new(to).iso_code, + from: from, + to: to, date: date if response.success? - ExchangeRate.new \ - base_currency: from, - converted_currency: to, + rate = ExchangeRate.new \ + from_currency: from, + to_currency: to, rate: response.rate, date: date + + rate.save! if cache + rate else - raise response.error + nil end end end diff --git a/app/models/family.rb b/app/models/family.rb index 5e81e5f4..b0be8b1e 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -97,11 +97,11 @@ class Family < ApplicationRecord end def assets - Money.new(accounts.active.assets.map { |account| account.balance_money.exchange_to(currency) || 0 }.sum, currency) + Money.new(accounts.active.assets.map { |account| account.balance_money.exchange_to(currency, fallback_rate: 0) }.sum, currency) end def liabilities - Money.new(accounts.active.liabilities.map { |account| account.balance_money.exchange_to(currency) || 0 }.sum, currency) + Money.new(accounts.active.liabilities.map { |account| account.balance_money.exchange_to(currency, fallback_rate: 0) }.sum, currency) end def sync_accounts diff --git a/app/models/provider/synth.rb b/app/models/provider/synth.rb index 9966b92f..c432d63d 100644 --- a/app/models/provider/synth.rb +++ b/app/models/provider/synth.rb @@ -1,12 +1,8 @@ class Provider::Synth include Retryable - def initialize(api_key = ENV["SYNTH_API_KEY"]) - @api_key = api_key || ENV["SYNTH_API_KEY"] - end - - def configured? - @api_key.present? + def initialize(api_key) + @api_key = api_key end def fetch_exchange_rate(from:, to:, date:) diff --git a/db/migrate/20240706151026_rename_rate_fields.rb b/db/migrate/20240706151026_rename_rate_fields.rb new file mode 100644 index 00000000..ad54304d --- /dev/null +++ b/db/migrate/20240706151026_rename_rate_fields.rb @@ -0,0 +1,6 @@ +class RenameRateFields < ActiveRecord::Migration[7.2] + def change + rename_column :exchange_rates, :base_currency, :from_currency + rename_column :exchange_rates, :converted_currency, :to_currency + end +end diff --git a/db/schema.rb b/db/schema.rb index f64eea1f..778d5b34 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: 2024_06_28_104551) do +ActiveRecord::Schema[7.2].define(version: 2024_07_06_151026) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -145,15 +145,15 @@ ActiveRecord::Schema[7.2].define(version: 2024_06_28_104551) do end create_table "exchange_rates", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "base_currency", null: false - t.string "converted_currency", null: false + t.string "from_currency", null: false + t.string "to_currency", null: false t.decimal "rate" t.date "date" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["base_currency", "converted_currency", "date"], name: "index_exchange_rates_on_base_converted_date_unique", unique: true - t.index ["base_currency"], name: "index_exchange_rates_on_base_currency" - t.index ["converted_currency"], name: "index_exchange_rates_on_converted_currency" + t.index ["from_currency", "to_currency", "date"], name: "index_exchange_rates_on_base_converted_date_unique", unique: true + t.index ["from_currency"], name: "index_exchange_rates_on_from_currency" + t.index ["to_currency"], name: "index_exchange_rates_on_to_currency" end create_table "families", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| diff --git a/lib/money.rb b/lib/money.rb index a9d55d37..a4cd7764 100644 --- a/lib/money.rb +++ b/lib/money.rb @@ -2,7 +2,10 @@ class Money include Comparable, Arithmetic include ActiveModel::Validations - attr_reader :amount, :currency + class ConversionError < StandardError + end + + attr_reader :amount, :currency, :store validate :source_must_be_of_known_type @@ -16,20 +19,27 @@ class Money end end - def initialize(obj, currency = Money.default_currency) + def initialize(obj, currency = Money.default_currency, store: ExchangeRate) @source = obj @amount = obj.is_a?(Money) ? obj.amount : BigDecimal(obj.to_s) @currency = obj.is_a?(Money) ? obj.currency : Money::Currency.new(currency) + @store = store validate! end - # TODO: Replace with injected rate store - def exchange_to(other_currency, date = Date.current) - if currency == Money::Currency.new(other_currency) + def exchange_to(other_currency, date: Date.current, fallback_rate: nil) + iso_code = currency.iso_code + other_iso_code = Money::Currency.new(other_currency).iso_code + + if iso_code == other_iso_code self - elsif rate = ExchangeRate.find_rate(from: currency, to: other_currency, date: date) - Money.new(amount * rate.rate, other_currency) + else + exchange_rate = store.find_rate(from: iso_code, to: other_iso_code, date: date)&.rate || fallback_rate + + raise ConversionError.new("Couldn't find exchange rate from #{iso_code} to #{other_iso_code} on #{date}") unless exchange_rate + + Money.new(amount * exchange_rate, other_iso_code) end end diff --git a/lib/tasks/demo_data.rake b/lib/tasks/demo_data.rake index cba30ad1..556ac270 100644 --- a/lib/tasks/demo_data.rake +++ b/lib/tasks/demo_data.rake @@ -45,8 +45,8 @@ namespace :demo_data do exchange_rates = (0..60).map do |days_ago| { date: Date.current - days_ago.days, - base_currency: "EUR", - converted_currency: "USD", + from_currency: "EUR", + to_currency: "USD", rate: rand(1.0840..1.0924).round(4) } end @@ -54,18 +54,18 @@ namespace :demo_data do exchange_rates += (0..20).map do |days_ago| { date: Date.current - days_ago.days, - base_currency: "BTC", - converted_currency: "USD", + from_currency: "BTC", + to_currency: "USD", rate: rand(60000..65000).round(2) } end # Multi-currency account needs a few USD:EUR rates exchange_rates += [ - { date: Date.current - 45.days, base_currency: "USD", converted_currency: "EUR", rate: 0.89 }, - { date: Date.current - 34.days, base_currency: "USD", converted_currency: "EUR", rate: 0.87 }, - { date: Date.current - 28.days, base_currency: "USD", converted_currency: "EUR", rate: 0.88 }, - { date: Date.current - 14.days, base_currency: "USD", converted_currency: "EUR", rate: 0.86 } + { date: Date.current - 45.days, from_currency: "USD", to_currency: "EUR", rate: 0.89 }, + { date: Date.current - 34.days, from_currency: "USD", to_currency: "EUR", rate: 0.87 }, + { date: Date.current - 28.days, from_currency: "USD", to_currency: "EUR", rate: 0.88 }, + { date: Date.current - 14.days, from_currency: "USD", to_currency: "EUR", rate: 0.86 } ] ExchangeRate.insert_all(exchange_rates) diff --git a/test/fixtures/exchange_rates.yml b/test/fixtures/exchange_rates.yml index 533d16cf..197b17d6 100644 --- a/test/fixtures/exchange_rates.yml +++ b/test/fixtures/exchange_rates.yml @@ -1,383 +1,11 @@ -day_31_ago_eur_to_usd: - base_currency: EUR - converted_currency: USD +one: + from_currency: EUR + to_currency: GBP rate: 1.0986 - date: <%= 31.days.ago.to_date %> + date: <%= Date.current %> -day_30_ago_eur_to_usd: - base_currency: EUR - converted_currency: USD +two: + from_currency: EUR + to_currency: GBP rate: 1.0926 - date: <%= 30.days.ago.to_date %> - -day_29_ago_eur_to_usd: - base_currency: EUR - converted_currency: USD - rate: 1.094 - date: <%= 29.days.ago.to_date %> - -day_28_ago_eur_to_usd: - base_currency: EUR - converted_currency: USD - rate: 1.095 - date: <%= 28.days.ago.to_date %> - -day_27_ago_eur_to_usd: - base_currency: EUR - converted_currency: USD - rate: 1.0898 - date: <%= 27.days.ago.to_date %> - -day_26_ago_eur_to_usd: - base_currency: EUR - converted_currency: USD - rate: 1.0858 - date: <%= 26.days.ago.to_date %> - -day_25_ago_eur_to_usd: - base_currency: EUR - converted_currency: USD - rate: 1.0856 - date: <%= 25.days.ago.to_date %> - -day_24_ago_eur_to_usd: - base_currency: EUR - converted_currency: USD - rate: 1.084 - date: <%= 24.days.ago.to_date %> - -day_23_ago_eur_to_usd: - base_currency: EUR - converted_currency: USD - rate: 1.0807 - date: <%= 23.days.ago.to_date %> - -day_22_ago_eur_to_usd: - base_currency: EUR - converted_currency: USD - rate: 1.0839 - date: <%= 22.days.ago.to_date %> - -day_21_ago_eur_to_usd: - base_currency: EUR - converted_currency: USD - rate: 1.0845 - date: <%= 21.days.ago.to_date %> - -day_20_ago_eur_to_usd: - base_currency: EUR - converted_currency: USD - rate: 1.0854 - date: <%= 20.days.ago.to_date %> - -day_19_ago_eur_to_usd: - base_currency: EUR - converted_currency: USD - rate: 1.0822 - date: <%= 19.days.ago.to_date %> - -day_18_ago_eur_to_usd: - base_currency: EUR - converted_currency: USD - rate: 1.0824 - date: <%= 18.days.ago.to_date %> - -day_17_ago_eur_to_usd: - base_currency: EUR - converted_currency: USD - rate: 1.0818 - date: <%= 17.days.ago.to_date %> - -day_16_ago_eur_to_usd: - base_currency: EUR - converted_currency: USD - rate: 1.0809 - date: <%= 16.days.ago.to_date %> - -day_15_ago_eur_to_usd: - base_currency: EUR - converted_currency: USD - rate: 1.078 - date: <%= 15.days.ago.to_date %> - -day_14_ago_eur_to_usd: - base_currency: EUR - converted_currency: USD - rate: 1.0778 - date: <%= 14.days.ago.to_date %> - -day_13_ago_eur_to_usd: - base_currency: EUR - converted_currency: USD - rate: 1.0773 - date: <%= 13.days.ago.to_date %> - -day_12_ago_eur_to_usd: - base_currency: EUR - converted_currency: USD - rate: 1.0729 - date: <%= 12.days.ago.to_date %> - -day_11_ago_eur_to_usd: - base_currency: EUR - converted_currency: USD - rate: 1.0709 - date: <%= 11.days.ago.to_date %> - -day_10_ago_eur_to_usd: - base_currency: EUR - converted_currency: USD - rate: 1.0773 - date: <%= 10.days.ago.to_date %> - -day_9_ago_eur_to_usd: - base_currency: EUR - converted_currency: USD - rate: 1.0783 - date: <%= 9.days.ago.to_date %> - -day_8_ago_eur_to_usd: - base_currency: EUR - converted_currency: USD - rate: 1.0778 - date: <%= 8.days.ago.to_date %> - -day_7_ago_eur_to_usd: - base_currency: EUR - converted_currency: USD - rate: 1.0774 - date: <%= 7.days.ago.to_date %> - -day_6_ago_eur_to_usd: - base_currency: EUR - converted_currency: USD - rate: 1.0755 - date: <%= 6.days.ago.to_date %> - -day_5_ago_eur_to_usd: - base_currency: EUR - converted_currency: USD - rate: 1.0743 - date: <%= 5.days.ago.to_date %> - -day_4_ago_eur_to_usd: - base_currency: EUR - converted_currency: USD - rate: 1.0788 - date: <%= 4.days.ago.to_date %> - -day_3_ago_eur_to_usd: - base_currency: EUR - converted_currency: USD - rate: 1.0872 - date: <%= 3.days.ago.to_date %> - -day_2_ago_eur_to_usd: - base_currency: EUR - converted_currency: USD - rate: 1.0819 - date: <%= 2.days.ago.to_date %> - -day_1_ago_eur_to_usd: - base_currency: EUR - converted_currency: USD - rate: 1.0845 - date: <%= 1.days.ago.to_date %> - -today_eur_to_usd: - base_currency: EUR - converted_currency: USD - rate: 1.0834 - date: <%= Date.current %> - -day_31_ago_usd_to_eur: - base_currency: USD - converted_currency: EUR - rate: 0.9279 - date: <%= 31.days.ago.to_date %> - -day_30_ago_usd_to_eur: - base_currency: USD - converted_currency: EUR - rate: 0.9179 - date: <%= 30.days.ago.to_date %> - -day_29_ago_usd_to_eur: - base_currency: USD - converted_currency: EUR - rate: 0.9154 - date: <%= 29.days.ago.to_date %> - -day_28_ago_usd_to_eur: - base_currency: USD - converted_currency: EUR - rate: 0.9107 - date: <%= 28.days.ago.to_date %> - -day_27_ago_usd_to_eur: - base_currency: USD - converted_currency: EUR - rate: 0.9139 - date: <%= 27.days.ago.to_date %> - -day_26_ago_usd_to_eur: - base_currency: USD - converted_currency: EUR - rate: 0.9082 - date: <%= 26.days.ago.to_date %> - -day_25_ago_usd_to_eur: - base_currency: USD - converted_currency: EUR - rate: 0.9077 - date: <%= 25.days.ago.to_date %> - -day_24_ago_usd_to_eur: - base_currency: USD - converted_currency: EUR - rate: 0.9054 - date: <%= 24.days.ago.to_date %> - -day_23_ago_usd_to_eur: - base_currency: USD - converted_currency: EUR - rate: 0.9004 - date: <%= 23.days.ago.to_date %> - -day_22_ago_usd_to_eur: - base_currency: USD - converted_currency: EUR - rate: 0.9040 - date: <%= 22.days.ago.to_date %> - -day_21_ago_usd_to_eur: - base_currency: USD - converted_currency: EUR - rate: 0.9060 - date: <%= 21.days.ago.to_date %> - -day_20_ago_usd_to_eur: - base_currency: USD - converted_currency: EUR - rate: 0.9052 - date: <%= 20.days.ago.to_date %> - -day_19_ago_usd_to_eur: - base_currency: USD - converted_currency: EUR - rate: 0.9139 - date: <%= 19.days.ago.to_date %> - -day_18_ago_usd_to_eur: - base_currency: USD - converted_currency: EUR - rate: 0.9155 - date: <%= 18.days.ago.to_date %> - -day_17_ago_usd_to_eur: - base_currency: USD - converted_currency: EUR - rate: 0.9135 - date: <%= 17.days.ago.to_date %> - -day_16_ago_usd_to_eur: - base_currency: USD - converted_currency: EUR - rate: 0.9141 - date: <%= 16.days.ago.to_date %> - -day_15_ago_usd_to_eur: - base_currency: USD - converted_currency: EUR - rate: 0.9131 - date: <%= 15.days.ago.to_date %> - -day_14_ago_usd_to_eur: - base_currency: USD - converted_currency: EUR - rate: 0.9147 - date: <%= 14.days.ago.to_date %> - -day_13_ago_usd_to_eur: - base_currency: USD - converted_currency: EUR - rate: 0.9112 - date: <%= 13.days.ago.to_date %> - -day_12_ago_usd_to_eur: - base_currency: USD - converted_currency: EUR - rate: 0.9115 - date: <%= 12.days.ago.to_date %> - -day_11_ago_usd_to_eur: - base_currency: USD - converted_currency: EUR - rate: 0.9132 - date: <%= 11.days.ago.to_date %> - -day_10_ago_usd_to_eur: - base_currency: USD - converted_currency: EUR - rate: 0.9130 - date: <%= 10.days.ago.to_date %> - -day_9_ago_usd_to_eur: - base_currency: USD - converted_currency: EUR - rate: 0.9192 - date: <%= 9.days.ago.to_date %> - -day_8_ago_usd_to_eur: - base_currency: USD - converted_currency: EUR - rate: 0.9188 - date: <%= 8.days.ago.to_date %> - -day_7_ago_usd_to_eur: - base_currency: USD - converted_currency: EUR - rate: 0.9194 - date: <%= 7.days.ago.to_date %> - -day_6_ago_usd_to_eur: - base_currency: USD - converted_currency: EUR - rate: 0.9177 - date: <%= 6.days.ago.to_date %> - -day_5_ago_usd_to_eur: - base_currency: USD - converted_currency: EUR - rate: 0.9187 - date: <%= 5.days.ago.to_date %> - -day_4_ago_usd_to_eur: - base_currency: USD - converted_currency: EUR - rate: 0.9213 - date: <%= 4.days.ago.to_date %> - -day_3_ago_usd_to_eur: - base_currency: USD - converted_currency: EUR - rate: 0.9186 - date: <%= 3.days.ago.to_date %> - -day_2_ago_usd_to_eur: - base_currency: USD - converted_currency: EUR - rate: 0.9218 - date: <%= 2.days.ago.to_date %> - -day_1_ago_usd_to_eur: - base_currency: USD - converted_currency: EUR - rate: 0.9213 - date: <%= 1.days.ago.to_date %> - -today_usd_to_eur: - base_currency: USD - converted_currency: EUR - rate: 0.9141 - date: <%= Date.current %> + date: <%= 1.day.ago.to_date %> diff --git a/test/lib/money_test.rb b/test/lib/money_test.rb index be9b738e..e13a00e7 100644 --- a/test/lib/money_test.rb +++ b/test/lib/money_test.rb @@ -1,99 +1,112 @@ require "test_helper" +require "ostruct" class MoneyTest < ActiveSupport::TestCase - test "can create with default currency" do - value = Money.new(1000) - assert_equal 1000, value.amount - end + test "can create with default currency" do + value = Money.new(1000) + assert_equal 1000, value.amount + end - test "can create with custom currency" do - value1 = Money.new(1000, :EUR) - value2 = Money.new(1000, :eur) - value3 = Money.new(1000, "eur") - value4 = Money.new(1000, "EUR") + test "can create with custom currency" do + value1 = Money.new(1000, :EUR) + value2 = Money.new(1000, :eur) + value3 = Money.new(1000, "eur") + value4 = Money.new(1000, "EUR") - assert_equal value1.currency.iso_code, value2.currency.iso_code - assert_equal value2.currency.iso_code, value3.currency.iso_code - assert_equal value3.currency.iso_code, value4.currency.iso_code - end + assert_equal value1.currency.iso_code, value2.currency.iso_code + assert_equal value2.currency.iso_code, value3.currency.iso_code + assert_equal value3.currency.iso_code, value4.currency.iso_code + end - test "equality tests amount and currency" do - assert_equal Money.new(1000), Money.new(1000) - assert_not_equal Money.new(1000), Money.new(1001) - assert_not_equal Money.new(1000, :usd), Money.new(1000, :eur) - end + test "equality tests amount and currency" do + assert_equal Money.new(1000), Money.new(1000) + assert_not_equal Money.new(1000), Money.new(1001) + assert_not_equal Money.new(1000, :usd), Money.new(1000, :eur) + end - test "can compare with zero Numeric" do - assert_equal Money.new(0), 0 - assert_raises(TypeError) { Money.new(1) == 1 } - end + test "can compare with zero Numeric" do + assert_equal Money.new(0), 0 + assert_raises(TypeError) { Money.new(1) == 1 } + end - test "can negate" do - assert_equal (-Money.new(1000)), Money.new(-1000) - end + test "can negate" do + assert_equal (-Money.new(1000)), Money.new(-1000) + end - test "can use comparison operators" do - assert_operator Money.new(1000), :>, Money.new(999) - assert_operator Money.new(1000), :>=, Money.new(1000) - assert_operator Money.new(1000), :<, Money.new(1001) - assert_operator Money.new(1000), :<=, Money.new(1000) - end + test "can use comparison operators" do + assert_operator Money.new(1000), :>, Money.new(999) + assert_operator Money.new(1000), :>=, Money.new(1000) + assert_operator Money.new(1000), :<, Money.new(1001) + assert_operator Money.new(1000), :<=, Money.new(1000) + end - test "can add and subtract" do - assert_equal Money.new(1000) + Money.new(1000), Money.new(2000) - assert_equal Money.new(1000) + 1000, Money.new(2000) - assert_equal Money.new(1000) - Money.new(1000), Money.new(0) - assert_equal Money.new(1000) - 1000, Money.new(0) - end + test "can add and subtract" do + assert_equal Money.new(1000) + Money.new(1000), Money.new(2000) + assert_equal Money.new(1000) + 1000, Money.new(2000) + assert_equal Money.new(1000) - Money.new(1000), Money.new(0) + assert_equal Money.new(1000) - 1000, Money.new(0) + end - test "can multiply" do - assert_equal Money.new(1000) * 2, Money.new(2000) - assert_raises(TypeError) { Money.new(1000) * Money.new(2) } - end + test "can multiply" do + assert_equal Money.new(1000) * 2, Money.new(2000) + assert_raises(TypeError) { Money.new(1000) * Money.new(2) } + end - test "can divide" do - assert_equal Money.new(1000) / 2, Money.new(500) - assert_equal Money.new(1000) / Money.new(500), 2 - assert_raise(TypeError) { 1000 / Money.new(2) } - end + test "can divide" do + assert_equal Money.new(1000) / 2, Money.new(500) + assert_equal Money.new(1000) / Money.new(500), 2 + assert_raise(TypeError) { 1000 / Money.new(2) } + end - test "operator order does not matter" do - assert_equal Money.new(1000) + 1000, 1000 + Money.new(1000) - assert_equal Money.new(1000) - 1000, 1000 - Money.new(1000) - assert_equal Money.new(1000) * 2, 2 * Money.new(1000) - end + test "operator order does not matter" do + assert_equal Money.new(1000) + 1000, 1000 + Money.new(1000) + assert_equal Money.new(1000) - 1000, 1000 - Money.new(1000) + assert_equal Money.new(1000) * 2, 2 * Money.new(1000) + end - test "can get absolute value" do - assert_equal Money.new(1000).abs, Money.new(1000) - assert_equal Money.new(-1000).abs, Money.new(1000) - end + test "can get absolute value" do + assert_equal Money.new(1000).abs, Money.new(1000) + assert_equal Money.new(-1000).abs, Money.new(1000) + end - test "can test if zero" do - assert Money.new(0).zero? - assert_not Money.new(1000).zero? - end + test "can test if zero" do + assert Money.new(0).zero? + assert_not Money.new(1000).zero? + end - test "can test if negative" do - assert Money.new(-1000).negative? - assert_not Money.new(1000).negative? - end + test "can test if negative" do + assert Money.new(-1000).negative? + assert_not Money.new(1000).negative? + end - test "can test if positive" do - assert Money.new(1000).positive? - assert_not Money.new(-1000).positive? - end + test "can test if positive" do + assert Money.new(1000).positive? + assert_not Money.new(-1000).positive? + end - test "can cast to string with basic formatting" do - assert_equal "$1,000.90", Money.new(1000.899).format - assert_equal "€1.000,12", Money.new(1000.12, :eur).format - end + test "can cast to string with basic formatting" do + assert_equal "$1,000.90", Money.new(1000.899).format + assert_equal "€1.000,12", Money.new(1000.12, :eur).format + end - test "can exchange to another currency" do - er = exchange_rates(:today_usd_to_eur) - assert_equal Money.new(1000).exchange_to(:eur), Money.new(1000 * er.rate, :eur) - end + test "converts currency when rate available" do + ExchangeRate.expects(:find_rate).returns(OpenStruct.new(rate: 1.2)) - test "returns nil if exchange rate not available" do - assert_nil Money.new(1000).exchange_to(:jpy) + assert_equal Money.new(1000).exchange_to(:eur), Money.new(1000 * 1.2, :eur) + end + + test "raises when no conversion rate available and no fallback rate provided" do + ExchangeRate.expects(:find_rate).returns(nil) + + assert_raises Money::ConversionError do + Money.new(1000).exchange_to(:jpy) end + end + + test "converts currency with a fallback rate" do + ExchangeRate.expects(:find_rate).returns(nil).twice + + assert_equal 0, Money.new(1000).exchange_to(:jpy, fallback_rate: 0) + assert_equal Money.new(1000, :jpy), Money.new(1000, :usd).exchange_to(:jpy, fallback_rate: 1) + end end diff --git a/test/models/account/balance/calculator_test.rb b/test/models/account/balance/calculator_test.rb index 88d728ff..bac5d4c3 100644 --- a/test/models/account/balance/calculator_test.rb +++ b/test/models/account/balance/calculator_test.rb @@ -25,6 +25,17 @@ class Account::Balance::CalculatorTest < ActiveSupport::TestCase end test "syncs foreign checking account balances" do + required_exchange_rates_for_sync = [ + 1.0834, 1.0845, 1.0819, 1.0872, 1.0788, 1.0743, 1.0755, 1.0774, + 1.0778, 1.0783, 1.0773, 1.0709, 1.0729, 1.0773, 1.0778, 1.078, + 1.0809, 1.0818, 1.0824, 1.0822, 1.0854, 1.0845, 1.0839, 1.0807, + 1.084, 1.0856, 1.0858, 1.0898, 1.095, 1.094, 1.0926, 1.0986 + ] + + required_exchange_rates_for_sync.each_with_index do |exchange_rate, idx| + ExchangeRate.create! date: idx.days.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: exchange_rate + end + # Foreign accounts will generate balances for all currencies expected_usd_balances = get_expected_balances_for(:eur_checking_usd) expected_eur_balances = get_expected_balances_for(:eur_checking_eur) @@ -38,6 +49,13 @@ class Account::Balance::CalculatorTest < ActiveSupport::TestCase end test "syncs multi-currency checking account balances" do + required_exchange_rates_for_sync = [ + { from_currency: "EUR", to_currency: "USD", date: 4.days.ago.to_date, rate: 1.0788 }, + { from_currency: "EUR", to_currency: "USD", date: 19.days.ago.to_date, rate: 1.0822 } + ] + + ExchangeRate.insert_all(required_exchange_rates_for_sync) + expected_balances = get_expected_balances_for(:multi_currency) assert_account_balances calculated_balances_for(:multi_currency), expected_balances end diff --git a/test/models/account/syncable_test.rb b/test/models/account/syncable_test.rb index 30f686f1..6eb8c808 100644 --- a/test/models/account/syncable_test.rb +++ b/test/models/account/syncable_test.rb @@ -18,15 +18,14 @@ class Account::SyncableTest < ActiveSupport::TestCase assert_equal 32, @account.balances.count end - test "syncs foreign currency account" do - account = accounts(:eur_checking) - account.sync - assert_equal "ok", account.status - assert_equal 32, account.balances.where(currency: "USD").count - assert_equal 32, account.balances.where(currency: "EUR").count - end - test "syncs multi currency account" do + required_exchange_rates_for_sync = [ + { from_currency: "EUR", to_currency: "USD", date: 4.days.ago.to_date, rate: 1.0788 }, + { from_currency: "EUR", to_currency: "USD", date: 19.days.ago.to_date, rate: 1.0822 } + ] + + ExchangeRate.insert_all(required_exchange_rates_for_sync) + account = accounts(:multi_currency) account.sync assert_equal "ok", account.status @@ -91,6 +90,17 @@ class Account::SyncableTest < ActiveSupport::TestCase end test "foreign currency account has balances in each currency after syncing" do + required_exchange_rates_for_sync = [ + 1.0834, 1.0845, 1.0819, 1.0872, 1.0788, 1.0743, 1.0755, 1.0774, + 1.0778, 1.0783, 1.0773, 1.0709, 1.0729, 1.0773, 1.0778, 1.078, + 1.0809, 1.0818, 1.0824, 1.0822, 1.0854, 1.0845, 1.0839, 1.0807, + 1.084, 1.0856, 1.0858, 1.0898, 1.095, 1.094, 1.0926, 1.0986 + ] + + required_exchange_rates_for_sync.each_with_index do |exchange_rate, idx| + ExchangeRate.create! date: idx.days.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: exchange_rate + end + account = accounts(:eur_checking) account.sync diff --git a/test/models/exchange_rate_test.rb b/test/models/exchange_rate_test.rb index ac2ddf55..c2398be9 100644 --- a/test/models/exchange_rate_test.rb +++ b/test/models/exchange_rate_test.rb @@ -1,50 +1,95 @@ require "test_helper" require "ostruct" + class ExchangeRateTest < ActiveSupport::TestCase - 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) + setup do + @provider = mock + + ExchangeRate.stubs(:exchange_rates_provider).returns(@provider) end - test "fetch rate from provider when it's not found in db" do - with_env_overrides SYNTH_API_KEY: "true" do - ExchangeRate - .expects(:fetch_rate_from_provider) - .returns(ExchangeRate.new(base_currency: "USD", converted_currency: "MXN", rate: 1.0, date: Date.current)) + test "exchange rate provider nil if no api key configured" do + ExchangeRate.unstub(:exchange_rates_provider) - ExchangeRate.find_rate_or_fetch from: "USD", to: "MXN", date: Date.current + with_env_overrides SYNTH_API_KEY: nil do + assert_nil ExchangeRate.exchange_rates_provider end end - test "provided rates are saved to the db" do - with_env_overrides SYNTH_API_KEY: "true" 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 + test "finds single rate in DB" do + @provider.expects(:fetch_exchange_rate).never + + rate = exchange_rates(:one) + + assert_equal exchange_rates(:one), ExchangeRate.find_rate(from: rate.from_currency, to: rate.to_currency, date: rate.date) + end + + test "finds single rate from provider and caches to DB" do + expected_rate = 1.21 + @provider.expects(:fetch_exchange_rate).once.returns(OpenStruct.new(success?: true, rate: expected_rate)) + + fetched_rate = ExchangeRate.find_rate(from: "USD", to: "EUR", date: Date.current, cache: true) + refetched_rate = ExchangeRate.find_rate(from: "USD", to: "EUR", date: Date.current, cache: true) + + assert_equal expected_rate, fetched_rate.rate + assert_equal expected_rate, refetched_rate.rate + end + + test "nil if rate is not found in DB and provider throws an error" do + @provider.expects(:fetch_exchange_rate).with(from: "USD", to: "EUR", date: Date.current).once.returns(OpenStruct.new(success?: false)) + + assert_nil ExchangeRate.find_rate(from: "USD", to: "EUR", date: Date.current) + end + + test "nil if rate is not found in DB and provider is disabled" do + ExchangeRate.unstub(:exchange_rates_provider) + + with_env_overrides SYNTH_API_KEY: nil do + assert_nil ExchangeRate.find_rate(from: "USD", to: "EUR", date: Date.current) end end - test "retrying, then raising on provider error" do - with_env_overrides SYNTH_API_KEY: "true" do - Faraday.expects(:get).returns(OpenStruct.new(success?: false)).times(3) + test "finds multiple rates in DB" do + @provider.expects(:fetch_exchange_rate).never - error = assert_raises Provider::Base::ProviderError do - ExchangeRate.find_rate_or_fetch from: "USD", to: "MXN", date: Date.current - end + rate1 = exchange_rates(:one) # EUR -> GBP, today + rate2 = exchange_rates(:two) # EUR -> GBP, yesterday - assert_match "Failed to fetch exchange rate from Provider::Synth", error.message - end + fetched_rates = ExchangeRate.find_rates(from: rate1.from_currency, to: rate1.to_currency, start_date: 1.day.ago.to_date).sort_by(&:date) + + assert_equal rate1, fetched_rates[1] + assert_equal rate2, fetched_rates[0] end - test "retrying, then raising on network error" do - with_env_overrides SYNTH_API_KEY: "true" do - Faraday.expects(:get).raises(Faraday::TimeoutError).times(3) + test "finds multiple rates from provider and caches to DB" do + @provider.expects(:fetch_exchange_rate).with(from: "EUR", to: "USD", date: 1.day.ago.to_date).returns(OpenStruct.new(success?: true, rate: 1.1)).once + @provider.expects(:fetch_exchange_rate).with(from: "EUR", to: "USD", date: Date.current).returns(OpenStruct.new(success?: true, rate: 1.2)).once - assert_raises Faraday::TimeoutError do - ExchangeRate.find_rate_or_fetch from: "USD", to: "MXN", date: Date.current - end + fetched_rates = ExchangeRate.find_rates(from: "EUR", to: "USD", start_date: 1.day.ago.to_date, cache: true) + refetched_rates = ExchangeRate.find_rates(from: "EUR", to: "USD", start_date: 1.day.ago.to_date) + + assert_equal [ 1.1, 1.2 ], fetched_rates.sort_by(&:date).map(&:rate) + assert_equal [ 1.1, 1.2 ], refetched_rates.sort_by(&:date).map(&:rate) + end + + test "finds missing db rates from provider and appends to results" do + @provider.expects(:fetch_exchange_rate).with(from: "EUR", to: "GBP", date: 2.days.ago.to_date).returns(OpenStruct.new(success?: true, rate: 1.1)).once + + rate1 = exchange_rates(:one) # EUR -> GBP, today + rate2 = exchange_rates(:two) # EUR -> GBP, yesterday + + fetched_rates = ExchangeRate.find_rates(from: "EUR", to: "GBP", start_date: 2.days.ago.to_date, cache: true) + refetched_rates = ExchangeRate.find_rates(from: "EUR", to: "GBP", start_date: 2.days.ago.to_date) + + assert_equal [ 1.1, rate2.rate, rate1.rate ], fetched_rates.sort_by(&:date).map(&:rate) + assert_equal [ 1.1, rate2.rate, rate1.rate ], refetched_rates.sort_by(&:date).map(&:rate) + end + + test "returns empty array if no rates found in DB or provider" do + ExchangeRate.unstub(:exchange_rates_provider) + + with_env_overrides SYNTH_API_KEY: nil do + assert_equal [], ExchangeRate.find_rates(from: "USD", to: "JPY", start_date: 10.days.ago.to_date) end end end diff --git a/test/models/family_test.rb b/test/models/family_test.rb index 96eb2cdc..9efc06ed 100644 --- a/test/models/family_test.rb +++ b/test/models/family_test.rb @@ -6,6 +6,18 @@ class FamilyTest < ActiveSupport::TestCase def setup @family = families(:dylan_family) + + required_exchange_rates_for_family = [ + 1.0834, 1.0845, 1.0819, 1.0872, 1.0788, 1.0743, 1.0755, 1.0774, + 1.0778, 1.0783, 1.0773, 1.0709, 1.0729, 1.0773, 1.0778, 1.078, + 1.0809, 1.0818, 1.0824, 1.0822, 1.0854, 1.0845, 1.0839, 1.0807, + 1.084, 1.0856, 1.0858, 1.0898, 1.095, 1.094, 1.0926, 1.0986 + ] + + required_exchange_rates_for_family.each_with_index do |exchange_rate, idx| + ExchangeRate.create! date: idx.days.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: exchange_rate + end + @family.accounts.each do |account| account.sync end diff --git a/test/models/provider/synth_test.rb b/test/models/provider/synth_test.rb index 74f98f07..5559e7cb 100644 --- a/test/models/provider/synth_test.rb +++ b/test/models/provider/synth_test.rb @@ -1,9 +1,26 @@ require "test_helper" +require "ostruct" class Provider::SynthTest < ActiveSupport::TestCase include ExchangeRateProviderInterfaceTest setup do - @subject = Provider::Synth.new + @subject = @synth = Provider::Synth.new("fookey") + end + + test "retries then provides failed response" do + Faraday.expects(:get).returns(OpenStruct.new(success?: false)).times(3) + + response = @synth.fetch_exchange_rate from: "USD", to: "MXN", date: Date.current + + assert_match "Failed to fetch exchange rate from Provider::Synth", response.error.message + end + + test "retrying, then raising on network error" do + Faraday.expects(:get).raises(Faraday::TimeoutError).times(3) + + assert_raises Faraday::TimeoutError do + @synth.fetch_exchange_rate from: "USD", to: "MXN", date: Date.current + end end end