historical-bank 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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