1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-19 21:29:38 +02:00

Handle missing exchange rate provider, allow fallback for missing rates (#955)

* Clean up exchange rate logic

* Remove stale method
This commit is contained in:
Zach Gollwitzer 2024-07-08 09:04:59 -04:00 committed by GitHub
parent bef335c631
commit 6767aaed1d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 383 additions and 609 deletions

View file

@ -28,6 +28,51 @@ class Account < ApplicationRecord
delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy 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) def balance_on(date)
balances.where("date <= ?", date).order(date: :desc).first&.balance balances.where("date <= ?", date).order(date: :desc).first&.balance
end end
@ -50,57 +95,11 @@ class Account < ApplicationRecord
balance_series = balances.in_period(period).where(currency: Money::Currency.new(currency).iso_code) balance_series = balances.in_period(period).where(currency: Money::Currency.new(currency).iso_code)
if balance_series.empty? && period.date_range.end == Date.current if balance_series.empty? && period.date_range.end == Date.current
converted_balance = balance_money.exchange_to(currency) TimeSeries.new([ { date: Date.current, value: balance_money.exchange_to(currency) } ])
if converted_balance
TimeSeries.new([ { date: Date.current, value: converted_balance } ])
else
TimeSeries.new([])
end
else else
TimeSeries.from_collection(balance_series, :balance_money) TimeSeries.from_collection(balance_series, :balance_money)
end end
end rescue Money::ConversionError
TimeSeries.new([])
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
end end
end end

View file

@ -72,10 +72,10 @@ class Account::Balance::Calculator
end end
def convert_balances_to_family_currency(balances) def convert_balances_to_family_currency(balances)
rates = ExchangeRate.get_rates( rates = ExchangeRate.find_rates(
account.currency, from: account.currency,
account.family.currency, to: account.family.currency,
calc_start_date..Date.current start_date: calc_start_date
).to_a ).to_a
# Abort conversion if some required rates are missing # Abort conversion if some required rates are missing
@ -84,8 +84,9 @@ class Account::Balance::Calculator
return [] return []
end end
balances.map.with_index do |balance, index| balances.map do |balance|
converted_balance = balance[:balance] * rates[index].rate 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 } { date: balance[:date], balance: converted_balance, currency: account.family.currency, updated_at: Time.current }
end end
end end

View file

@ -22,7 +22,7 @@ class Account::Entry < ApplicationRecord
"account_entries.*", "account_entries.*",
"account_entries.amount * COALESCE(er.rate, 1) AS converted_amount" "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) .where("er.rate IS NOT NULL OR account_entries.currency = ?", currency)
} }

View file

@ -65,10 +65,10 @@ module Account::Syncable
return if rate_candidates.blank? return if rate_candidates.blank?
existing_rates = ExchangeRate.where( existing_rates = ExchangeRate.where(
base_currency: rate_candidates.map { |rc| rc[:from_currency] }, from_currency: rate_candidates.map { |rc| rc[:from_currency] },
converted_currency: rate_candidates.map { |rc| rc[:to_currency] }, to_currency: rate_candidates.map { |rc| rc[:to_currency] },
date: rate_candidates.map { |rc| rc[:date] } 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 # Convert to a set for faster lookup
existing_rates_set = existing_rates.map { |er| [ er[0], er[1], er[2].to_s ] }.to_set existing_rates_set = existing_rates.map { |er| [ er[0], er[1], er[2].to_s ] }.to_set

View file

@ -7,7 +7,13 @@ module Providable
class_methods do class_methods do
def exchange_rates_provider 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 end
def git_repository_provider def git_repository_provider

View file

@ -1,29 +1,29 @@
class ExchangeRate < ApplicationRecord class ExchangeRate < ApplicationRecord
include Provided include Provided
validates :base_currency, :converted_currency, presence: true validates :from_currency, :to_currency, :date, :rate, presence: true
class << self class << self
def find_rate(from:, to:, date:) def find_rate(from:, to:, date:, cache: true)
find_by \ result = find_by \
base_currency: Money::Currency.new(from).iso_code, from_currency: from,
converted_currency: Money::Currency.new(to).iso_code, to_currency: to,
date: date date: date
result || fetch_rate_from_provider(from:, to:, date:, cache:)
end end
def find_rate_or_fetch(from:, to:, date:) def find_rates(from:, to:, start_date:, end_date: Date.current, cache: true)
find_rate(from:, to:, date:) || fetch_rate_from_provider(from:, to:, date:)&.tap(&:save!) rates = self.where(from_currency: from, to_currency: to, date: start_date..end_date).to_a
end 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) if missing_dates.any?
where(base_currency: from, converted_currency: to, date: dates).order(:date) rates += fetch_rates_from_provider(from:, to:, dates: missing_dates, cache:)
end end
def convert(value:, from:, to:, date:) rates
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
end end
end end
end end

View file

@ -1,25 +1,38 @@
module ExchangeRate::Provided module ExchangeRate::Provided
extend ActiveSupport::Concern extend ActiveSupport::Concern
include Providable include Providable
class_methods do class_methods do
private 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 \ response = exchange_rates_provider.fetch_exchange_rate \
from: Money::Currency.new(from).iso_code, from: from,
to: Money::Currency.new(to).iso_code, to: to,
date: date date: date
if response.success? if response.success?
ExchangeRate.new \ rate = ExchangeRate.new \
base_currency: from, from_currency: from,
converted_currency: to, to_currency: to,
rate: response.rate, rate: response.rate,
date: date date: date
rate.save! if cache
rate
else else
raise response.error nil
end end
end end
end end

View file

@ -97,11 +97,11 @@ class Family < ApplicationRecord
end end
def assets 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 end
def liabilities 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 end
def sync_accounts def sync_accounts

View file

@ -1,12 +1,8 @@
class Provider::Synth class Provider::Synth
include Retryable include Retryable
def initialize(api_key = ENV["SYNTH_API_KEY"]) def initialize(api_key)
@api_key = api_key || ENV["SYNTH_API_KEY"] @api_key = api_key
end
def configured?
@api_key.present?
end end
def fetch_exchange_rate(from:, to:, date:) def fetch_exchange_rate(from:, to:, date:)

View file

@ -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

12
db/schema.rb generated
View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto" enable_extension "pgcrypto"
enable_extension "plpgsql" enable_extension "plpgsql"
@ -145,15 +145,15 @@ ActiveRecord::Schema[7.2].define(version: 2024_06_28_104551) do
end end
create_table "exchange_rates", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| create_table "exchange_rates", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "base_currency", null: false t.string "from_currency", null: false
t.string "converted_currency", null: false t.string "to_currency", null: false
t.decimal "rate" t.decimal "rate"
t.date "date" t.date "date"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_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 ["from_currency", "to_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 ["from_currency"], name: "index_exchange_rates_on_from_currency"
t.index ["converted_currency"], name: "index_exchange_rates_on_converted_currency" t.index ["to_currency"], name: "index_exchange_rates_on_to_currency"
end end
create_table "families", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| create_table "families", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|

View file

@ -2,7 +2,10 @@ class Money
include Comparable, Arithmetic include Comparable, Arithmetic
include ActiveModel::Validations include ActiveModel::Validations
attr_reader :amount, :currency class ConversionError < StandardError
end
attr_reader :amount, :currency, :store
validate :source_must_be_of_known_type validate :source_must_be_of_known_type
@ -16,20 +19,27 @@ class Money
end end
end end
def initialize(obj, currency = Money.default_currency) def initialize(obj, currency = Money.default_currency, store: ExchangeRate)
@source = obj @source = obj
@amount = obj.is_a?(Money) ? obj.amount : BigDecimal(obj.to_s) @amount = obj.is_a?(Money) ? obj.amount : BigDecimal(obj.to_s)
@currency = obj.is_a?(Money) ? obj.currency : Money::Currency.new(currency) @currency = obj.is_a?(Money) ? obj.currency : Money::Currency.new(currency)
@store = store
validate! validate!
end end
# TODO: Replace with injected rate store def exchange_to(other_currency, date: Date.current, fallback_rate: nil)
def exchange_to(other_currency, date = Date.current) iso_code = currency.iso_code
if currency == Money::Currency.new(other_currency) other_iso_code = Money::Currency.new(other_currency).iso_code
if iso_code == other_iso_code
self self
elsif rate = ExchangeRate.find_rate(from: currency, to: other_currency, date: date) else
Money.new(amount * rate.rate, other_currency) 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
end end

View file

@ -45,8 +45,8 @@ namespace :demo_data do
exchange_rates = (0..60).map do |days_ago| exchange_rates = (0..60).map do |days_ago|
{ {
date: Date.current - days_ago.days, date: Date.current - days_ago.days,
base_currency: "EUR", from_currency: "EUR",
converted_currency: "USD", to_currency: "USD",
rate: rand(1.0840..1.0924).round(4) rate: rand(1.0840..1.0924).round(4)
} }
end end
@ -54,18 +54,18 @@ namespace :demo_data do
exchange_rates += (0..20).map do |days_ago| exchange_rates += (0..20).map do |days_ago|
{ {
date: Date.current - days_ago.days, date: Date.current - days_ago.days,
base_currency: "BTC", from_currency: "BTC",
converted_currency: "USD", to_currency: "USD",
rate: rand(60000..65000).round(2) rate: rand(60000..65000).round(2)
} }
end end
# Multi-currency account needs a few USD:EUR rates # Multi-currency account needs a few USD:EUR rates
exchange_rates += [ exchange_rates += [
{ date: Date.current - 45.days, base_currency: "USD", converted_currency: "EUR", rate: 0.89 }, { date: Date.current - 45.days, from_currency: "USD", to_currency: "EUR", rate: 0.89 },
{ date: Date.current - 34.days, base_currency: "USD", converted_currency: "EUR", rate: 0.87 }, { date: Date.current - 34.days, from_currency: "USD", to_currency: "EUR", rate: 0.87 },
{ date: Date.current - 28.days, base_currency: "USD", converted_currency: "EUR", rate: 0.88 }, { date: Date.current - 28.days, from_currency: "USD", to_currency: "EUR", rate: 0.88 },
{ date: Date.current - 14.days, base_currency: "USD", converted_currency: "EUR", rate: 0.86 } { date: Date.current - 14.days, from_currency: "USD", to_currency: "EUR", rate: 0.86 }
] ]
ExchangeRate.insert_all(exchange_rates) ExchangeRate.insert_all(exchange_rates)

View file

@ -1,383 +1,11 @@
day_31_ago_eur_to_usd: one:
base_currency: EUR from_currency: EUR
converted_currency: USD to_currency: GBP
rate: 1.0986 rate: 1.0986
date: <%= 31.days.ago.to_date %> date: <%= Date.current %>
day_30_ago_eur_to_usd: two:
base_currency: EUR from_currency: EUR
converted_currency: USD to_currency: GBP
rate: 1.0926 rate: 1.0926
date: <%= 30.days.ago.to_date %> date: <%= 1.day.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 %>

View file

@ -1,99 +1,112 @@
require "test_helper" require "test_helper"
require "ostruct"
class MoneyTest < ActiveSupport::TestCase class MoneyTest < ActiveSupport::TestCase
test "can create with default currency" do test "can create with default currency" do
value = Money.new(1000) value = Money.new(1000)
assert_equal 1000, value.amount assert_equal 1000, value.amount
end end
test "can create with custom currency" do test "can create with custom currency" do
value1 = Money.new(1000, :EUR) value1 = Money.new(1000, :EUR)
value2 = Money.new(1000, :eur) value2 = Money.new(1000, :eur)
value3 = Money.new(1000, "eur") value3 = Money.new(1000, "eur")
value4 = Money.new(1000, "EUR") value4 = Money.new(1000, "EUR")
assert_equal value1.currency.iso_code, value2.currency.iso_code assert_equal value1.currency.iso_code, value2.currency.iso_code
assert_equal value2.currency.iso_code, value3.currency.iso_code assert_equal value2.currency.iso_code, value3.currency.iso_code
assert_equal value3.currency.iso_code, value4.currency.iso_code assert_equal value3.currency.iso_code, value4.currency.iso_code
end end
test "equality tests amount and currency" do test "equality tests amount and currency" do
assert_equal Money.new(1000), Money.new(1000) assert_equal Money.new(1000), Money.new(1000)
assert_not_equal Money.new(1000), Money.new(1001) assert_not_equal Money.new(1000), Money.new(1001)
assert_not_equal Money.new(1000, :usd), Money.new(1000, :eur) assert_not_equal Money.new(1000, :usd), Money.new(1000, :eur)
end end
test "can compare with zero Numeric" do test "can compare with zero Numeric" do
assert_equal Money.new(0), 0 assert_equal Money.new(0), 0
assert_raises(TypeError) { Money.new(1) == 1 } assert_raises(TypeError) { Money.new(1) == 1 }
end end
test "can negate" do test "can negate" do
assert_equal (-Money.new(1000)), Money.new(-1000) assert_equal (-Money.new(1000)), Money.new(-1000)
end end
test "can use comparison operators" do test "can use comparison operators" do
assert_operator Money.new(1000), :>, Money.new(999) assert_operator Money.new(1000), :>, Money.new(999)
assert_operator Money.new(1000), :>=, Money.new(1000) assert_operator Money.new(1000), :>=, Money.new(1000)
assert_operator Money.new(1000), :<, Money.new(1001) assert_operator Money.new(1000), :<, Money.new(1001)
assert_operator Money.new(1000), :<=, Money.new(1000) assert_operator Money.new(1000), :<=, Money.new(1000)
end end
test "can add and subtract" do test "can add and subtract" do
assert_equal Money.new(1000) + Money.new(1000), Money.new(2000) 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) + 1000, Money.new(2000)
assert_equal Money.new(1000) - Money.new(1000), Money.new(0) assert_equal Money.new(1000) - Money.new(1000), Money.new(0)
assert_equal Money.new(1000) - 1000, Money.new(0) assert_equal Money.new(1000) - 1000, Money.new(0)
end end
test "can multiply" do test "can multiply" do
assert_equal Money.new(1000) * 2, Money.new(2000) assert_equal Money.new(1000) * 2, Money.new(2000)
assert_raises(TypeError) { Money.new(1000) * Money.new(2) } assert_raises(TypeError) { Money.new(1000) * Money.new(2) }
end end
test "can divide" do test "can divide" do
assert_equal Money.new(1000) / 2, Money.new(500) assert_equal Money.new(1000) / 2, Money.new(500)
assert_equal Money.new(1000) / Money.new(500), 2 assert_equal Money.new(1000) / Money.new(500), 2
assert_raise(TypeError) { 1000 / Money.new(2) } assert_raise(TypeError) { 1000 / Money.new(2) }
end end
test "operator order does not matter" do 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) - 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) assert_equal Money.new(1000) * 2, 2 * Money.new(1000)
end end
test "can get absolute value" do test "can get absolute value" do
assert_equal Money.new(1000).abs, Money.new(1000) assert_equal Money.new(1000).abs, Money.new(1000)
assert_equal Money.new(-1000).abs, Money.new(1000) assert_equal Money.new(-1000).abs, Money.new(1000)
end end
test "can test if zero" do test "can test if zero" do
assert Money.new(0).zero? assert Money.new(0).zero?
assert_not Money.new(1000).zero? assert_not Money.new(1000).zero?
end end
test "can test if negative" do test "can test if negative" do
assert Money.new(-1000).negative? assert Money.new(-1000).negative?
assert_not Money.new(1000).negative? assert_not Money.new(1000).negative?
end end
test "can test if positive" do test "can test if positive" do
assert Money.new(1000).positive? assert Money.new(1000).positive?
assert_not Money.new(-1000).positive? assert_not Money.new(-1000).positive?
end end
test "can cast to string with basic formatting" do test "can cast to string with basic formatting" do
assert_equal "$1,000.90", Money.new(1000.899).format assert_equal "$1,000.90", Money.new(1000.899).format
assert_equal "€1.000,12", Money.new(1000.12, :eur).format assert_equal "€1.000,12", Money.new(1000.12, :eur).format
end end
test "can exchange to another currency" do test "converts currency when rate available" do
er = exchange_rates(:today_usd_to_eur) ExchangeRate.expects(:find_rate).returns(OpenStruct.new(rate: 1.2))
assert_equal Money.new(1000).exchange_to(:eur), Money.new(1000 * er.rate, :eur)
end
test "returns nil if exchange rate not available" do assert_equal Money.new(1000).exchange_to(:eur), Money.new(1000 * 1.2, :eur)
assert_nil Money.new(1000).exchange_to(:jpy) 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
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 end

View file

@ -25,6 +25,17 @@ class Account::Balance::CalculatorTest < ActiveSupport::TestCase
end end
test "syncs foreign checking account balances" do 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 # Foreign accounts will generate balances for all currencies
expected_usd_balances = get_expected_balances_for(:eur_checking_usd) expected_usd_balances = get_expected_balances_for(:eur_checking_usd)
expected_eur_balances = get_expected_balances_for(:eur_checking_eur) expected_eur_balances = get_expected_balances_for(:eur_checking_eur)
@ -38,6 +49,13 @@ class Account::Balance::CalculatorTest < ActiveSupport::TestCase
end end
test "syncs multi-currency checking account balances" do 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) expected_balances = get_expected_balances_for(:multi_currency)
assert_account_balances calculated_balances_for(:multi_currency), expected_balances assert_account_balances calculated_balances_for(:multi_currency), expected_balances
end end

View file

@ -18,15 +18,14 @@ class Account::SyncableTest < ActiveSupport::TestCase
assert_equal 32, @account.balances.count assert_equal 32, @account.balances.count
end 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 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 = accounts(:multi_currency)
account.sync account.sync
assert_equal "ok", account.status assert_equal "ok", account.status
@ -91,6 +90,17 @@ class Account::SyncableTest < ActiveSupport::TestCase
end end
test "foreign currency account has balances in each currency after syncing" do 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 = accounts(:eur_checking)
account.sync account.sync

View file

@ -1,50 +1,95 @@
require "test_helper" require "test_helper"
require "ostruct" require "ostruct"
class ExchangeRateTest < ActiveSupport::TestCase class ExchangeRateTest < ActiveSupport::TestCase
test "find rate in db" do setup do
assert_equal exchange_rates(:day_29_ago_eur_to_usd), @provider = mock
ExchangeRate.find_rate_or_fetch(from: "EUR", to: "USD", date: 29.days.ago.to_date)
ExchangeRate.stubs(:exchange_rates_provider).returns(@provider)
end end
test "fetch rate from provider when it's not found in db" do test "exchange rate provider nil if no api key configured" do
with_env_overrides SYNTH_API_KEY: "true" do ExchangeRate.unstub(:exchange_rates_provider)
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 with_env_overrides SYNTH_API_KEY: nil do
assert_nil ExchangeRate.exchange_rates_provider
end end
end end
test "provided rates are saved to the db" do test "finds single rate in DB" do
with_env_overrides SYNTH_API_KEY: "true" do @provider.expects(:fetch_exchange_rate).never
VCR.use_cassette "synth_exchange_rate" do
assert_difference "ExchangeRate.count", 1 do rate = exchange_rates(:one)
ExchangeRate.find_rate_or_fetch from: "USD", to: "MXN", date: Date.current
end assert_equal exchange_rates(:one), ExchangeRate.find_rate(from: rate.from_currency, to: rate.to_currency, date: rate.date)
end 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
end end
test "retrying, then raising on provider error" do test "finds multiple rates in DB" do
with_env_overrides SYNTH_API_KEY: "true" do @provider.expects(:fetch_exchange_rate).never
Faraday.expects(:get).returns(OpenStruct.new(success?: false)).times(3)
error = assert_raises Provider::Base::ProviderError do rate1 = exchange_rates(:one) # EUR -> GBP, today
ExchangeRate.find_rate_or_fetch from: "USD", to: "MXN", date: Date.current rate2 = exchange_rates(:two) # EUR -> GBP, yesterday
end
assert_match "Failed to fetch exchange rate from Provider::Synth", error.message fetched_rates = ExchangeRate.find_rates(from: rate1.from_currency, to: rate1.to_currency, start_date: 1.day.ago.to_date).sort_by(&:date)
end
assert_equal rate1, fetched_rates[1]
assert_equal rate2, fetched_rates[0]
end end
test "retrying, then raising on network error" do test "finds multiple rates from provider and caches to DB" do
with_env_overrides SYNTH_API_KEY: "true" 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
Faraday.expects(:get).raises(Faraday::TimeoutError).times(3) @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 fetched_rates = ExchangeRate.find_rates(from: "EUR", to: "USD", start_date: 1.day.ago.to_date, cache: true)
ExchangeRate.find_rate_or_fetch from: "USD", to: "MXN", date: Date.current refetched_rates = ExchangeRate.find_rates(from: "EUR", to: "USD", start_date: 1.day.ago.to_date)
end
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 end
end end

View file

@ -6,6 +6,18 @@ class FamilyTest < ActiveSupport::TestCase
def setup def setup
@family = families(:dylan_family) @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| @family.accounts.each do |account|
account.sync account.sync
end end

View file

@ -1,9 +1,26 @@
require "test_helper" require "test_helper"
require "ostruct"
class Provider::SynthTest < ActiveSupport::TestCase class Provider::SynthTest < ActiveSupport::TestCase
include ExchangeRateProviderInterfaceTest include ExchangeRateProviderInterfaceTest
setup do 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
end end