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:
parent
bef335c631
commit
6767aaed1d
20 changed files with 383 additions and 609 deletions
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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:)
|
||||||
|
|
6
db/migrate/20240706151026_rename_rate_fields.rb
Normal file
6
db/migrate/20240706151026_rename_rate_fields.rb
Normal 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
12
db/schema.rb
generated
|
@ -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|
|
||||||
|
|
24
lib/money.rb
24
lib/money.rb
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
388
test/fixtures/exchange_rates.yml
vendored
388
test/fixtures/exchange_rates.yml
vendored
|
@ -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 %>
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue