historical-bank 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,123 @@
1
+ #
2
+ # Copyright 2017 Skyscanner Limited.
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ #
16
+
17
+ # frozen_string_literal: true
18
+
19
+ require 'money'
20
+ require 'httparty'
21
+
22
+ class Money
23
+ module RatesProvider
24
+ # Raised when a +RatesProvider+ request fails
25
+ class RequestFailed < StandardError; end
26
+
27
+ # Retrieves exchange rates from OpenExchangeRates.org API, relative
28
+ # to the given +base_currency+.
29
+ # It is fetching rates for all currencies in one request, as we are charged on a
30
+ # "per date" basis. I.e. one month's data for all currencies counts as 30 calls.
31
+ class OpenExchangeRates
32
+ include HTTParty
33
+ base_uri 'https://openexchangerates.org/api'
34
+
35
+ # minimum date that OER has data
36
+ # (https://docs.openexchangerates.org/docs/historical-json)
37
+ MIN_DATE = Date.new(1999, 1, 1).freeze
38
+
39
+ # ==== Parameters
40
+ # - +oer_app_id+ - App ID for the OpenExchangeRates API access (Enterprise or Unlimited plan)
41
+ # - +base_currency+ - The base currency that will be used for the OER requests. It should be a +Money::Currency+ object.
42
+ # - +timeout+ - The timeout in seconds to set on the requests
43
+ def initialize(oer_app_id, base_currency, timeout)
44
+ @oer_app_id = oer_app_id
45
+ @base_currency_code = base_currency.iso_code
46
+ @timeout = timeout
47
+ end
48
+
49
+ # Fetches the rates for all available quote currencies for a whole month.
50
+ # Fetching for all currencies or just one has the same API charge.
51
+ # In addition, the API doesn't allow fetching more than a month's data.
52
+ #
53
+ # It returns a +Hash+ with the rates for each quote currency and date
54
+ # as shown in the example. Rates are +BigDecimal+.
55
+ #
56
+ # ==== Parameters
57
+ #
58
+ # - +date+ - +date+'s month is the month for which we request rates. Minimum +date+ is January 1st 1999, as defined by the OER API (https://docs.openexchangerates.org/docs/api-introduction). Maximum +date+ is yesterday (UTC), as today's rates are not final (https://openexchangerates.org/faq/#timezone).
59
+ #
60
+ # ==== Errors
61
+ #
62
+ # - Raises +ArgumentError+ when +date+ is less than January 1st 1999, or greater than yesterday (UTC)
63
+ # - Raises +Money::RatesProvider::RequestFailed+ when the OER request fails
64
+ #
65
+ # ==== Examples
66
+ #
67
+ # oer.fetch_month_rates(Date.new(2016, 10, 5))
68
+ # # => {"AED"=>{"2016-10-01"=>#<BigDecimal:7fa19a188e98,'0.3672682E1',18(36)>, "2016-10-02"=>#<BigDecimal:7fa19b11a5c8,'0.367296E1',18(36)>, ...
69
+ def fetch_month_rates(date)
70
+ if date < MIN_DATE || date > max_date
71
+ raise ArgumentError, "Provided date #{date} for OER query should be "\
72
+ "between #{MIN_DATE} and #{max_date}"
73
+ end
74
+
75
+ end_of_month = Date.civil(date.year, date.month, -1)
76
+
77
+ start_date = Date.civil(date.year, date.month, 1)
78
+ end_date = [end_of_month, max_date].min
79
+
80
+ options = request_options(start_date, end_date)
81
+ response = self.class.get('/time-series.json', options)
82
+
83
+ unless response.success?
84
+ raise RequestFailed, "Month rates request failed for #{date} - "\
85
+ "Code: #{response.code} - Body: #{response.body}"
86
+ end
87
+
88
+ result = Hash.new { |hash, key| hash[key] = {} }
89
+
90
+ # sample response can be found in spec/fixtures.
91
+ # we're transforming the response from Hash[iso_date][iso_currency] to
92
+ # Hash[iso_currency][iso_date], as it will allow more efficient caching/retrieving
93
+ response['rates'].each do |iso_date, day_rates|
94
+ day_rates.each do |iso_currency, rate|
95
+ result[iso_currency][iso_date] = rate.to_d
96
+ end
97
+ end
98
+
99
+ result
100
+ end
101
+
102
+ private
103
+
104
+ def request_options(start_date, end_date)
105
+ {
106
+ query: {
107
+ app_id: @oer_app_id,
108
+ start: start_date,
109
+ end: end_date,
110
+ base: @base_currency_code
111
+ },
112
+ timeout: @timeout
113
+ }
114
+ end
115
+
116
+ # A historical day's rates can be obtained when the date changes at 00:00 UTC
117
+ # https://openexchangerates.org/faq/#timezone
118
+ def max_date
119
+ Time.now.utc.to_date - 1
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,133 @@
1
+ #
2
+ # Copyright 2017 Skyscanner Limited.
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ #
16
+
17
+ # frozen_string_literal: true
18
+
19
+ require 'money'
20
+ require 'redis'
21
+
22
+ class Money
23
+ module RatesStore
24
+ # Raised when a +RatesStore+ request fails
25
+ class RequestFailed < StandardError; end
26
+
27
+ # Exchange rates cache implemented with Redis.
28
+ #
29
+ # All cached rates are relative to the given +base_currency+.
30
+ # It stores +Hash+ es with keys formatted as +namespace:base_currency:quote_currency+ .
31
+ # These hashes contain ISO dates as keys with base currency rates as values.
32
+ #
33
+ # ==== Examples
34
+ #
35
+ # If +currency+ is the Redis namespace we used in the constructor,
36
+ # USD is the base currency, and we're looking for HUF rates, they can be found
37
+ # in the +currency:USD:HUF+ key, and their format will be
38
+ # {
39
+ # "2016-05-01": "0.272511002E3", # Rates are stored as BigDecimal Strings
40
+ # "2016-05-02": "0.270337998E3",
41
+ # "2016-05-03": "0.271477498E3"
42
+ # }
43
+ class HistoricalRedis
44
+ # ==== Parameters
45
+ #
46
+ # - +base_currency+ - The base currency relative to which all rates are stored
47
+ # - +redis_url+ - The URL of the Redis server
48
+ # - +namespace+ - Namespace with which to prefix all Redis keys
49
+
50
+ def initialize(base_currency, redis_url, namespace)
51
+ @base_currency = base_currency
52
+ @redis = Redis.new(url: redis_url)
53
+ @namespace = namespace
54
+ end
55
+
56
+ # Adds historical rates in bulk
57
+ #
58
+ # ==== Parameters
59
+ #
60
+ # +currency_date_rate_hash+ - A +Hash+ of exchange rates, broken down by currency and date. See the example for details.
61
+ #
62
+ # ==== Errors
63
+ #
64
+ # - Raises +ArgumentError+ when the base currency is included in +currency_date_rate_hash+ with rate other than 1.
65
+ # - Raises +Money::RatesStore::RequestFailed+ when the Redis request fails
66
+ #
67
+ # ==== Examples
68
+ #
69
+ # Assuming USD is the base currency
70
+ #
71
+ # rates = {
72
+ # 'EUR' => {
73
+ # '2015-09-10' => 0.11, # 1 USD = 0.11 EUR
74
+ # '2015-09-11' => 0.22
75
+ # },
76
+ # 'GBP' => {
77
+ # '2015-09-10' => 0.44, # 1 USD = 0.44 GBP
78
+ # '2015-09-11' => 0.55
79
+ # }
80
+ # }
81
+ # store.add_rates(rates)
82
+
83
+ def add_rates(currency_date_rate_hash)
84
+ if !currency_date_rate_hash[@base_currency.iso_code].nil? &&
85
+ !currency_date_rate_hash[@base_currency.iso_code].values.all? { |r| r == 1 }
86
+
87
+ raise ArgumentError, "When base currency #{@base_currency.iso_code} is included "\
88
+ "in given Hash #{currency_date_rate_hash}, its rate should "\
89
+ 'be equal to 1'
90
+ end
91
+
92
+ @redis.pipelined do
93
+ currency_date_rate_hash.each do |iso_currency, iso_date_rate_hash|
94
+ k = key(iso_currency)
95
+ @redis.mapped_hmset(k, iso_date_rate_hash)
96
+ end
97
+ end
98
+ rescue Redis::BaseError => e
99
+ raise RequestFailed, "Error while storing rates - #{e.message} - "\
100
+ "rates: #{currency_date_rate_hash}"
101
+ end
102
+
103
+ # Returns a +Hash+ of rates for all cached dates for the given currency.
104
+ #
105
+ # ==== Parameters
106
+ #
107
+ # - +currency+ - The quote currency for which we request all the cached rates. This is a +Money::Currency+ object.
108
+ #
109
+ # ==== Examples
110
+ #
111
+ # store.get_rates(Money::Currency.new('GBP'))
112
+ # # => {"2017-01-01"=>#<BigDecimal:7fa19ba27260,'0.809782E0',9(18)>, "2017-01-02"=>#<BigDecimal:7fa19ba27210,'0.814263E0',9(18)>, "2017-01-03"=>#<BigDecimal:7fa19ba271c0,'0.816721E0',9(18)>, ...
113
+ def get_rates(currency)
114
+ k = key(currency.iso_code)
115
+ iso_date_rate_hash = @redis.hgetall(k)
116
+
117
+ iso_date_rate_hash.each do |iso_date, rate_string|
118
+ iso_date_rate_hash[iso_date] = rate_string.to_d
119
+ end
120
+ rescue Redis::BaseError => e
121
+ raise RequestFailed, 'Error while retrieving rates for '\
122
+ "#{currency} - #{e.message}"\
123
+ end
124
+
125
+ private
126
+
127
+ # e.g. currency:EUR:USD
128
+ def key(currency_iso)
129
+ [@namespace, @base_currency.iso_code, currency_iso].join(':')
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,503 @@
1
+ #
2
+ # Copyright 2017 Skyscanner Limited.
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ #
16
+
17
+ # frozen_string_literal: true
18
+
19
+ require 'spec_helper'
20
+
21
+ class Money
22
+ module Bank
23
+ describe Historical do
24
+ let(:base_currency) { Currency.new('EUR') }
25
+ let(:redis_url) { "redis://localhost:#{ENV['REDIS_PORT']}" }
26
+ let(:redis) { Redis.new(port: ENV['REDIS_PORT']) }
27
+ let(:redis_namespace) { 'currency_test' }
28
+ let(:bank) { Historical.instance }
29
+
30
+ before do
31
+ Historical.configure do |config|
32
+ config.base_currency = base_currency
33
+ config.redis_url = redis_url
34
+ config.redis_namespace = redis_namespace
35
+ config.oer_app_id = SecureRandom.hex
36
+ config.timeout = 20
37
+ end
38
+ end
39
+
40
+ after do
41
+ keys = redis.keys("#{redis_namespace}*")
42
+ redis.del(keys) unless keys.empty?
43
+ end
44
+
45
+ describe '#add_rates' do
46
+ let(:usd_date_rate_hash) do
47
+ {
48
+ '2015-09-10' => 0.11111.to_d,
49
+ '2015-09-11' => 0.22222.to_d,
50
+ '2015-09-12' => 0.33333.to_d
51
+ }
52
+ end
53
+ let(:currency_date_rate_hash) do
54
+ {
55
+ 'USD' => usd_date_rate_hash,
56
+ 'GBP' => {
57
+ '2015-09-10' => 0.44444.to_d,
58
+ '2015-09-11' => 0.55555.to_d,
59
+ '2015-09-12' => 0.66666.to_d
60
+ },
61
+ 'VND' => {
62
+ '2015-09-10' => 0.77777.to_d,
63
+ '2015-09-11' => 0.88888.to_d,
64
+ '2015-09-12' => 0.99999.to_d
65
+ }
66
+ }
67
+ end
68
+ # it's pointing to the same cache as the one in bank
69
+ let(:new_store) do
70
+ RatesStore::HistoricalRedis.new(base_currency, redis_url, redis_namespace)
71
+ end
72
+
73
+ subject { bank.add_rates(currency_date_rate_hash) }
74
+
75
+ it 'sets the rates' do
76
+ subject
77
+ cached_rates = new_store.get_rates(Currency.new('USD'))
78
+
79
+ expect(cached_rates).to eq usd_date_rate_hash
80
+ end
81
+ end
82
+
83
+ describe '#add_rate' do
84
+ let(:rate) { rand }
85
+
86
+ # it's pointing to the same cache as the one in bank
87
+ let(:new_store) do
88
+ RatesStore::HistoricalRedis.new(base_currency, redis_url, redis_namespace)
89
+ end
90
+ let(:datetime) { Time.utc(2017, 1, 4, 13, 0, 0) }
91
+
92
+ subject { bank.add_rate(from_currency, to_currency, rate, datetime) }
93
+
94
+ context 'when base currency == from_currency' do
95
+ let(:from_currency) { 'EUR' }
96
+ let(:to_currency) { Money::Currency.new('USD') }
97
+
98
+ it "sets date's rate" do
99
+ subject
100
+ cached_rates = new_store.get_rates(to_currency)
101
+
102
+ cached_rate = cached_rates['2017-01-04']
103
+ expect(cached_rate).to be_within(0.00000001).of(rate)
104
+ end
105
+ end
106
+
107
+ context 'when base currency == to_currency' do
108
+ let(:from_currency) { Money::Currency.new('USD') }
109
+ let(:to_currency) { Money::Currency.new('EUR') }
110
+
111
+ it "sets date's rate as the inverse" do
112
+ subject
113
+ cached_rates = new_store.get_rates(from_currency)
114
+
115
+ cached_rate = cached_rates['2017-01-04']
116
+ expect(cached_rate).to be_within(0.00000001).of(1 / rate)
117
+ end
118
+ end
119
+
120
+ context 'when base currency does not match either of the passed currencies' do
121
+ let(:from_currency) { Money::Currency.new('USD') }
122
+ let(:to_currency) { 'GBP' }
123
+
124
+ it 'fails with ArgumentError' do
125
+ expect { subject }.to raise_error ArgumentError
126
+ end
127
+ end
128
+
129
+ context 'when datetime is a Date' do
130
+ let(:datetime) { Faker::Date.between(Date.today - 100, Date.today + 100) }
131
+ let(:from_currency) { 'EUR' }
132
+ let(:to_currency) { Money::Currency.new('USD') }
133
+
134
+ it "sets yesterday's rate" do
135
+ subject
136
+ cached_rates = new_store.get_rates(to_currency)
137
+
138
+ cached_rate = cached_rates[datetime.iso8601]
139
+ expect(cached_rate).to be_within(0.00000001).of(rate)
140
+ end
141
+ end
142
+
143
+ context 'when datetime is not passed' do
144
+ let(:from_currency) { 'EUR' }
145
+ let(:to_currency) { Money::Currency.new('USD') }
146
+ let(:now) { Time.utc(2017, 12, 5, 13, 0, 0) }
147
+
148
+ subject { bank.add_rate(from_currency, to_currency, rate) }
149
+
150
+ before { Timecop.travel(now) }
151
+ after { Timecop.return }
152
+
153
+ it "sets yesterday's rate" do
154
+ subject
155
+ cached_rates = new_store.get_rates(to_currency)
156
+
157
+ cached_rate = cached_rates['2017-12-04']
158
+ expect(cached_rate).to be_within(0.00000001).of(rate)
159
+ end
160
+ end
161
+ end
162
+
163
+ describe '#get_rate' do
164
+ let(:from_currency) { 'EUR' }
165
+ let(:to_currency) { Money::Currency.new('USD') }
166
+ let(:rate) { rand }
167
+
168
+ context 'when datetime is passed' do
169
+ subject { bank.get_rate(from_currency, to_currency, datetime) }
170
+
171
+ before { bank.add_rate(from_currency, to_currency, rate, datetime) }
172
+
173
+ context 'and it is a Time' do
174
+ let(:datetime) { Time.utc(2016, 3, 12, 14, 0, 0) }
175
+
176
+ it 'returns the correct rate' do
177
+ expect(subject).to be_within(0.00000001).of(rate)
178
+ end
179
+ end
180
+
181
+ context 'and it is a Date' do
182
+ let(:datetime) { Faker::Date.between(Date.today - 100, Date.today + 100) }
183
+
184
+ it 'returns the correct rate' do
185
+ expect(subject).to be_within(0.00000001).of(rate)
186
+ end
187
+ end
188
+ end
189
+
190
+ context 'when datetime is not passed' do
191
+ subject { bank.get_rate(from_currency, to_currency) }
192
+
193
+ before { bank.add_rate(from_currency, to_currency, rate) }
194
+
195
+ it "returns yesterday's rate" do
196
+ expect(subject).to be_within(0.00000001).of(rate)
197
+ end
198
+ end
199
+ end
200
+
201
+ describe '#exchange_with_historical' do
202
+ let(:from_currency) { Currency.wrap('USD') }
203
+ let(:from_money) { Money.new(10_000, from_currency) }
204
+ let(:to_currency) { Currency.wrap('GBP') }
205
+ let(:datetime) { Faker::Time.between(Date.today - 300, Date.today - 2) }
206
+ let(:date) { datetime.utc.to_date }
207
+ let(:from_currency_base_rate) { 1.1250721881474421.to_d }
208
+ let(:to_currency_base_rate) { 0.7346888078084116.to_d }
209
+ let(:from_currency_base_rates) do
210
+ {
211
+ (date - 1).iso8601 => rand,
212
+ date.iso8601 => from_currency_base_rate,
213
+ (date + 1).iso8601 => rand
214
+ }
215
+ end
216
+ let(:to_currency_base_rates) do
217
+ {
218
+ (date - 1).iso8601 => rand,
219
+ date.iso8601 => to_currency_base_rate,
220
+ (date + 1).iso8601 => rand
221
+ }
222
+ end
223
+ let(:expected_result) { Money.new(6530, to_currency) }
224
+
225
+ before do
226
+ allow_any_instance_of(RatesStore::HistoricalRedis).to receive(:get_rates)
227
+ .with(from_currency).and_return(from_currency_base_rates_store)
228
+ allow_any_instance_of(RatesStore::HistoricalRedis).to receive(:get_rates)
229
+ .with(to_currency).and_return(to_currency_base_rates_store)
230
+ allow_any_instance_of(RatesProvider::OpenExchangeRates)
231
+ .to receive(:fetch_month_rates).with(date).and_return(rates_provider)
232
+ end
233
+
234
+ subject { bank.exchange_with_historical(from_money, to_currency, datetime) }
235
+
236
+ describe 'to_currency parameter' do
237
+ let(:from_currency_base_rates_store) { from_currency_base_rates }
238
+ let(:to_currency_base_rates_store) { to_currency_base_rates }
239
+ let(:rates_provider) { nil }
240
+
241
+ context 'when iso code string' do
242
+ let(:to_currency) { 'GBP' }
243
+
244
+ it { is_expected.to eq expected_result }
245
+ end
246
+ end
247
+
248
+ describe 'datetime type' do
249
+ let(:datetime) { Faker::Time.between(Date.today - 300, Date.today - 2) }
250
+ let(:date) { datetime.utc.to_date }
251
+ let(:from_currency_base_rates_store) { from_currency_base_rates }
252
+ let(:to_currency_base_rates_store) { to_currency_base_rates }
253
+ let(:rates_provider) { nil }
254
+
255
+ it 'returns same result when passing a date or time on that date' do
256
+ time_result = bank.exchange_with_historical(from_money, to_currency, datetime)
257
+ date_result = bank.exchange_with_historical(from_money, to_currency, date)
258
+
259
+ expect(time_result).to eq date_result
260
+ end
261
+ end
262
+
263
+ describe 'selecting data source' do
264
+ context 'when both rates exist in Redis' do
265
+ let(:from_currency_base_rates_store) { from_currency_base_rates }
266
+ let(:to_currency_base_rates_store) { to_currency_base_rates }
267
+ let(:rates_provider) { nil }
268
+
269
+ it { is_expected.to eq expected_result }
270
+ end
271
+
272
+ context "when from_currency rate doesn't exist in Redis" do
273
+ let(:from_currency_base_rates_store) { nil }
274
+ let(:to_currency_base_rates_store) { to_currency_base_rates }
275
+ let(:rates_provider) { { from_currency.iso_code => from_currency_base_rates } }
276
+
277
+ it { is_expected.to eq expected_result }
278
+ end
279
+
280
+ context 'when from_currency rate exists in Redis for other dates' do
281
+ let(:from_currency_base_rates_other_dates) do
282
+ {
283
+ (date - 3).iso8601 => rand,
284
+ (date - 2).iso8601 => rand,
285
+ (date - 1).iso8601 => rand
286
+ }
287
+ end
288
+ let(:from_currency_base_rates_store) { from_currency_base_rates_other_dates }
289
+ let(:to_currency_base_rates_store) { to_currency_base_rates }
290
+ let(:rates_provider) { { from_currency.iso_code => from_currency_base_rates } }
291
+
292
+ it { is_expected.to eq expected_result }
293
+ end
294
+
295
+ context "when to_currency rate doesn't exist in Redis" do
296
+ let(:from_currency_base_rates_store) { from_currency_base_rates }
297
+ let(:to_currency_base_rates_store) { nil }
298
+ let(:rates_provider) { { to_currency.iso_code => to_currency_base_rates } }
299
+
300
+ it { is_expected.to eq expected_result }
301
+ end
302
+
303
+ context 'when to_currency rate exists in Redis for other dates' do
304
+ let(:to_currency_base_rates_other_dates) do
305
+ {
306
+ (date + 1).iso8601 => rand,
307
+ (date + 2).iso8601 => rand,
308
+ (date + 3).iso8601 => rand
309
+ }
310
+ end
311
+ let(:from_currency_base_rates_store) { from_currency_base_rates }
312
+ let(:to_currency_base_rates_store) { to_currency_base_rates_other_dates }
313
+ let(:rates_provider) { { to_currency.iso_code => to_currency_base_rates } }
314
+
315
+ it { is_expected.to eq expected_result }
316
+ end
317
+
318
+ context 'when neither of the rates exists in Redis' do
319
+ let(:from_currency_base_rates_store) { nil }
320
+ let(:to_currency_base_rates_store) { nil }
321
+ let(:rates_provider) do
322
+ {
323
+ from_currency.iso_code => from_currency_base_rates,
324
+ to_currency.iso_code => to_currency_base_rates
325
+ }
326
+ end
327
+
328
+ it { is_expected.to eq expected_result }
329
+ end
330
+
331
+ context 'when from_currency == to_currency' do
332
+ let(:to_currency) { from_money.currency }
333
+ let(:from_currency_base_rates_store) { nil }
334
+ let(:to_currency_base_rates_store) { nil }
335
+ let(:rates_provider) { nil }
336
+ let(:expected_result) { from_money }
337
+
338
+ it { is_expected.to eq expected_result }
339
+ end
340
+
341
+ context 'when from_currency == base_currency' do
342
+ let(:from_money) { Money.new(10_000, base_currency) }
343
+ let(:from_currency_base_rates_store) { nil }
344
+ let(:to_currency_base_rates_store) { to_currency_base_rates }
345
+ let(:rates_provider) { nil }
346
+ let(:expected_result) { Money.new(7347, to_currency) }
347
+
348
+ it { is_expected.to eq expected_result }
349
+ end
350
+
351
+ context 'when to_currency == base_currency' do
352
+ let(:to_currency) { base_currency }
353
+ let(:from_currency_base_rates_store) { from_currency_base_rates }
354
+ let(:to_currency_base_rates_store) { nil }
355
+ let(:rates_provider) { nil }
356
+ let(:expected_result) { Money.new(8888, to_currency) }
357
+
358
+ it { is_expected.to eq expected_result }
359
+ end
360
+ end
361
+
362
+ # taken from real rates from XE.com
363
+ describe 'money conversion' do
364
+ let(:from_currency_base_rates_store) { from_currency_base_rates }
365
+ let(:to_currency_base_rates_store) { to_currency_base_rates }
366
+ let(:rates_provider) { nil }
367
+
368
+ context 'for rates example 1' do
369
+ let(:from_currency) { Currency.wrap('USD') }
370
+ let(:from_money) { Money.new(500_00, from_currency) }
371
+ let(:to_currency) { Currency.wrap('GBP') }
372
+ let(:from_currency_base_rate) { 1.13597.to_d }
373
+ let(:to_currency_base_rate) { 0.735500.to_d }
374
+ let(:expected_result) { Money.new(323_73, to_currency) }
375
+
376
+ it { is_expected.to eq expected_result }
377
+ end
378
+
379
+ context 'for rates example 2' do
380
+ let(:from_currency) { Currency.wrap('INR') }
381
+ let(:from_money) { Money.new(6_516_200, from_currency) }
382
+ let(:to_currency) { Currency.wrap('CAD') }
383
+ let(:from_currency_base_rate) { 73.5602.to_d }
384
+ let(:to_currency_base_rate) { 1.46700.to_d }
385
+ let(:expected_result) { Money.new(1_299_52, to_currency) }
386
+
387
+ it { is_expected.to eq expected_result }
388
+ end
389
+
390
+ # VND has no decimal places
391
+ context 'for rates example 3' do
392
+ let(:from_currency) { Currency.wrap('SGD') }
393
+ let(:from_money) { Money.new(345_67, from_currency) }
394
+ let(:to_currency) { Currency.wrap('VND') }
395
+ let(:from_currency_base_rate) { 1.57222.to_d }
396
+ let(:to_currency_base_rate) { 25_160.75.to_d }
397
+ let(:expected_result) { Money.new(5_531_870, to_currency) }
398
+
399
+ it { is_expected.to eq expected_result }
400
+ end
401
+
402
+ # KWD has 3 decimal places
403
+ context 'for rates example 4' do
404
+ let(:from_currency) { Currency.wrap('CNY') }
405
+ let(:from_money) { Money.new(987_654, from_currency) }
406
+ let(:to_currency) { Currency.wrap('KWD') }
407
+ let(:from_currency_base_rate) { 7.21517.to_d }
408
+ let(:to_currency_base_rate) { 0.342725.to_d }
409
+ let(:expected_result) { Money.new(469_142, to_currency) }
410
+
411
+ it { is_expected.to eq expected_result }
412
+ end
413
+ end
414
+
415
+ context 'when OER client fails with ArgumentError' do
416
+ let(:datetime) { Faker::Time.between(Date.new(1990, 1, 1), Date.new(1998, 12, 31)) }
417
+ let(:from_currency_base_rates_store) { nil }
418
+ let(:to_currency_base_rates_store) { nil }
419
+ let(:rates_provider) { nil }
420
+
421
+ before do
422
+ # unstub and let it blow up
423
+ allow_any_instance_of(RatesProvider::OpenExchangeRates)
424
+ .to receive(:fetch_month_rates).and_call_original
425
+ end
426
+
427
+ it 'fails' do
428
+ expect { subject }.to raise_error(ArgumentError)
429
+ end
430
+ end
431
+ end
432
+
433
+ describe '#exchange_with' do
434
+ let(:from_currency) { Currency.wrap('USD') }
435
+ let(:from_money) { Money.new(10_000, from_currency) }
436
+ let(:to_currency) { Currency.wrap('GBP') }
437
+ let(:utc_date) { Time.now.utc.to_date }
438
+ let(:from_currency_base_rate) { 1.1250721881474421.to_d }
439
+ let(:to_currency_base_rate) { 0.7346888078084116.to_d }
440
+ let(:from_currency_base_rates_store) do
441
+ {
442
+ (utc_date - 3).iso8601 => rand,
443
+ (utc_date - 2).iso8601 => rand,
444
+ (utc_date - 1).iso8601 => from_currency_base_rate
445
+ }
446
+ end
447
+ let(:to_currency_base_rates_store) do
448
+ {
449
+ (utc_date - 3).iso8601 => rand,
450
+ (utc_date - 2).iso8601 => rand,
451
+ (utc_date - 1).iso8601 => to_currency_base_rate
452
+ }
453
+ end
454
+ let(:expected_result) { Money.new(6530, to_currency) }
455
+
456
+ before do
457
+ allow_any_instance_of(Money::RatesStore::HistoricalRedis).to receive(:get_rates)
458
+ .with(from_currency).and_return(from_currency_base_rates_store)
459
+ allow_any_instance_of(Money::RatesStore::HistoricalRedis).to receive(:get_rates)
460
+ .with(to_currency).and_return(to_currency_base_rates_store)
461
+ end
462
+
463
+ subject { bank.exchange_with(from_money, to_currency) }
464
+
465
+ it "selects yesterday's rates" do
466
+ expect(subject).to eq expected_result
467
+ end
468
+ end
469
+ end
470
+ end
471
+
472
+ describe Money do
473
+ describe '#exchange_with_historical' do
474
+ let(:base_currency) { Currency.new('EUR') }
475
+ let(:redis_url) { "redis://localhost:#{ENV['REDIS_PORT']}" }
476
+ let(:redis_namespace) { 'currency_test' }
477
+ let(:rates) do
478
+ {
479
+ 'USD' => { '2015-09-10' => 0.11 },
480
+ 'GBP' => { '2015-09-10' => 0.44 }
481
+ }
482
+ end
483
+ let(:money) { Money.new(100_00, 'USD') }
484
+ let(:to_currency) { Money::Currency.new('GBP') }
485
+ let(:datetime) { Date.new(2015, 9, 10) }
486
+
487
+ before do
488
+ Bank::Historical.configure do |config|
489
+ config.base_currency = base_currency
490
+ config.redis_url = redis_url
491
+ config.redis_namespace = redis_namespace
492
+ end
493
+
494
+ Bank::Historical.instance.add_rates(rates)
495
+ end
496
+
497
+ subject { money.exchange_to_historical(to_currency, datetime) }
498
+
499
+ # on Sept 10th, 100 EUR = 100 / 0.11 USD = 100 / 0.11 * 0.44 GBP = 400 GBP
500
+ it { is_expected.to eq Money.new(400_00, 'GBP') }
501
+ end
502
+ end
503
+ end