danconia 0.2.5 → 0.3.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.
- checksums.yaml +4 -4
- data/.rubocop.yml +104 -0
- data/.travis.yml +4 -0
- data/Gemfile.lock +40 -17
- data/README.md +16 -2
- data/bin/console +7 -0
- data/danconia.gemspec +5 -1
- data/examples/bna.rb +35 -0
- data/examples/currency_layer.rb +8 -4
- data/examples/fixed_rates.rb +1 -3
- data/examples/single_currency.rb +2 -3
- data/lib/danconia.rb +6 -4
- data/lib/danconia/currency.rb +2 -2
- data/lib/danconia/exchange.rb +47 -0
- data/lib/danconia/exchanges/bna.rb +61 -0
- data/lib/danconia/exchanges/currency_layer.rb +14 -2
- data/lib/danconia/exchanges/fixed_rates.rb +2 -4
- data/lib/danconia/integrations/active_record.rb +13 -14
- data/lib/danconia/money.rb +25 -24
- data/lib/danconia/pair.rb +15 -0
- data/lib/danconia/stores/active_record.rb +29 -6
- data/lib/danconia/stores/in_memory.rb +4 -9
- data/lib/danconia/version.rb +1 -1
- data/spec/danconia/exchanges/bna_spec.rb +36 -0
- data/spec/danconia/exchanges/currency_layer_spec.rb +28 -33
- data/spec/danconia/exchanges/exchange_spec.rb +54 -0
- data/spec/danconia/exchanges/fixtures/bna/home.html +124 -0
- data/spec/danconia/exchanges/fixtures/currency_layer/failure.json +7 -0
- data/spec/danconia/exchanges/fixtures/currency_layer/success.json +8 -0
- data/spec/danconia/integrations/active_record_spec.rb +25 -5
- data/spec/danconia/money_spec.rb +57 -15
- data/spec/danconia/stores/active_record_spec.rb +81 -15
- data/spec/danconia/stores/in_memory_spec.rb +18 -0
- data/spec/spec_helper.rb +2 -1
- metadata +67 -9
- data/lib/danconia/exchanges/exchange.rb +0 -31
- data/spec/danconia/exchanges/fixed_rates_spec.rb +0 -30
@@ -0,0 +1,61 @@
|
|
1
|
+
require 'danconia/errors/api_error'
|
2
|
+
require 'net/http'
|
3
|
+
require 'nokogiri'
|
4
|
+
|
5
|
+
module Danconia
|
6
|
+
module Exchanges
|
7
|
+
# The BNA does not provide an API to pull the rates, so this implementation scrapes the home HTML directly.
|
8
|
+
# Returns rates of both types, "Billetes" and "Divisas", and only the "tipo de cambio vendedor" ones.
|
9
|
+
# See `examples/bna.rb` for a complete usage example.
|
10
|
+
class BNA < Exchange
|
11
|
+
attr_reader :store
|
12
|
+
|
13
|
+
def initialize store: Stores::InMemory.new
|
14
|
+
@store = store
|
15
|
+
end
|
16
|
+
|
17
|
+
def fetch_rates
|
18
|
+
response = Net::HTTP.get URI 'https://www.bna.com.ar/Personas'
|
19
|
+
scrape_rates(response, 'billetes') + scrape_rates(response, 'divisas')
|
20
|
+
end
|
21
|
+
|
22
|
+
def update_rates!
|
23
|
+
@store.save_rates fetch_rates
|
24
|
+
end
|
25
|
+
|
26
|
+
def rates rate_type:, date: nil
|
27
|
+
array_of_rates_to_hash @store.rates(rate_type: rate_type, date: date)
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def scrape_rates response, type
|
33
|
+
doc = Nokogiri::XML(response).css("##{type}")
|
34
|
+
|
35
|
+
if doc.css('thead th:last-child').text != 'Venta'
|
36
|
+
raise Errors::APIError, "Could not scrape '#{type}' rates. Maybe the format changed?"
|
37
|
+
end
|
38
|
+
|
39
|
+
doc.css('tbody tr').map do |row|
|
40
|
+
pair = parse_pair(row.css('td:first-child').text) or next
|
41
|
+
rate = parse_rate(row.css('td:last-child').text, pair)
|
42
|
+
date = Date.parse(doc.css('.fechaCot').text)
|
43
|
+
{pair: pair, rate: rate, date: date, rate_type: type}
|
44
|
+
end.compact.presence or raise Errors::APIError, "Could not scrape '#{type}' rates. Maybe the format changed?"
|
45
|
+
end
|
46
|
+
|
47
|
+
def parse_pair cur_name
|
48
|
+
case cur_name
|
49
|
+
when 'Dolar U.S.A' then 'USDARS'
|
50
|
+
when 'Euro' then 'EURARS'
|
51
|
+
when 'Real *' then 'BRLARS'
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def parse_rate str, pair
|
56
|
+
val = Float(str.tr(',', '.'))
|
57
|
+
pair == 'BRLARS' ? val / 100.0 : val
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -4,10 +4,14 @@ require 'json'
|
|
4
4
|
|
5
5
|
module Danconia
|
6
6
|
module Exchanges
|
7
|
+
# Fetches the rates from https://currencylayer.com/. The `access_key` must be provided.
|
8
|
+
# See `examples/currency_layer.rb` for a complete usage example.
|
7
9
|
class CurrencyLayer < Exchange
|
8
|
-
|
9
|
-
|
10
|
+
attr_reader :store
|
11
|
+
|
12
|
+
def initialize access_key:, store: Stores::InMemory.new
|
10
13
|
@access_key = access_key
|
14
|
+
@store = store
|
11
15
|
end
|
12
16
|
|
13
17
|
def fetch_rates
|
@@ -18,6 +22,14 @@ module Danconia
|
|
18
22
|
raise Errors::APIError, response
|
19
23
|
end
|
20
24
|
end
|
25
|
+
|
26
|
+
def update_rates!
|
27
|
+
@store.save_rates fetch_rates.map { |pair, rate| {pair: pair, rate: rate} }
|
28
|
+
end
|
29
|
+
|
30
|
+
def rates **_opts
|
31
|
+
array_of_rates_to_hash @store.rates
|
32
|
+
end
|
21
33
|
end
|
22
34
|
end
|
23
35
|
end
|
@@ -1,4 +1,5 @@
|
|
1
1
|
require 'active_record'
|
2
|
+
require 'danconia/stores/active_record'
|
2
3
|
|
3
4
|
module Danconia
|
4
5
|
module Integrations
|
@@ -8,24 +9,22 @@ module Danconia
|
|
8
9
|
end
|
9
10
|
|
10
11
|
module ClassMethods
|
11
|
-
def money(*attr_names)
|
12
|
+
def money(*attr_names, **exchange_opts)
|
12
13
|
attr_names.each do |attr_name|
|
13
14
|
amount_column = attr_name
|
14
|
-
currency_column = "#{attr_name}_currency"
|
15
|
+
currency_column = :"#{attr_name}_currency"
|
15
16
|
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
end
|
17
|
+
define_method "#{attr_name}=" do |value|
|
18
|
+
write_attribute amount_column, value.is_a?(Money) ? value.amount : value
|
19
|
+
write_attribute currency_column, value.currency.code if respond_to?(currency_column) && value.is_a?(Money)
|
20
|
+
end
|
21
21
|
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
EOR
|
22
|
+
define_method attr_name do
|
23
|
+
amount = read_attribute amount_column
|
24
|
+
currency = read_attribute currency_column
|
25
|
+
decimals = self.class.columns.detect { |c| c.name == amount_column.to_s }.scale
|
26
|
+
Money.new amount, currency, decimals: decimals, exchange_opts: exchange_opts if amount
|
27
|
+
end
|
29
28
|
end
|
30
29
|
end
|
31
30
|
end
|
data/lib/danconia/money.rb
CHANGED
@@ -4,13 +4,13 @@ require 'danconia/errors/exchange_rate_not_found'
|
|
4
4
|
module Danconia
|
5
5
|
class Money
|
6
6
|
include Comparable
|
7
|
-
attr_reader :amount, :currency, :decimals
|
7
|
+
attr_reader :amount, :currency, :decimals
|
8
8
|
|
9
|
-
def initialize
|
10
|
-
@decimals = decimals
|
9
|
+
def initialize(amount, currency_code = nil, decimals: 2, exchange_opts: {})
|
11
10
|
@amount = parse amount
|
12
|
-
@
|
13
|
-
@
|
11
|
+
@decimals = decimals
|
12
|
+
@currency = Currency.find(currency_code || Danconia.config.default_currency)
|
13
|
+
@exchange_opts = exchange_opts.reverse_merge(exchange: Danconia.config.default_exchange)
|
14
14
|
end
|
15
15
|
|
16
16
|
def format decimals: @decimals, **other_options
|
@@ -41,29 +41,22 @@ module Danconia
|
|
41
41
|
end
|
42
42
|
|
43
43
|
def <=> other
|
44
|
-
|
45
|
-
amount <=> other
|
46
|
-
end
|
47
|
-
|
48
|
-
def exchange_to other_currency
|
49
|
-
other_currency = other_currency.presence && Currency.find(other_currency, exchange) || currency
|
50
|
-
rate = exchange_rate_to(other_currency.code)
|
51
|
-
clone_with amount * rate, other_currency
|
44
|
+
amount <=> amount_exchanged_to_this_currency(other)
|
52
45
|
end
|
53
46
|
|
54
|
-
def
|
55
|
-
|
56
|
-
|
57
|
-
exchange.rate
|
47
|
+
def exchange_to other_currency, **opts
|
48
|
+
opts = @exchange_opts.merge(opts)
|
49
|
+
other_currency = other_currency.presence && Currency.find(other_currency) || currency
|
50
|
+
rate = opts[:exchange].rate currency.code, other_currency.code, opts.except(:exchange)
|
51
|
+
clone_with amount * rate, other_currency, opts
|
58
52
|
end
|
59
53
|
|
60
|
-
%w
|
61
|
-
class_eval <<-
|
54
|
+
%w[+ - * /].each do |op|
|
55
|
+
class_eval <<-RUBY, __FILE__, __LINE__ + 1
|
62
56
|
def #{op} other
|
63
|
-
|
64
|
-
clone_with amount #{op} other
|
57
|
+
clone_with(amount #{op} amount_exchanged_to_this_currency(other))
|
65
58
|
end
|
66
|
-
|
59
|
+
RUBY
|
67
60
|
end
|
68
61
|
|
69
62
|
def round *args
|
@@ -100,8 +93,16 @@ module Danconia
|
|
100
93
|
BigDecimal(object.to_s) rescue BigDecimal(0)
|
101
94
|
end
|
102
95
|
|
103
|
-
def clone_with amount, currency = @currency
|
104
|
-
Money.new amount, currency, decimals: decimals,
|
96
|
+
def clone_with amount, currency = @currency, exchange_opts = @exchange_opts
|
97
|
+
Money.new amount, currency, decimals: @decimals, exchange_opts: exchange_opts
|
98
|
+
end
|
99
|
+
|
100
|
+
def amount_exchanged_to_this_currency other
|
101
|
+
if other.is_a? Money
|
102
|
+
other.exchange_to(currency, @exchange_opts).amount
|
103
|
+
else
|
104
|
+
other
|
105
|
+
end
|
105
106
|
end
|
106
107
|
end
|
107
108
|
end
|
@@ -1,20 +1,43 @@
|
|
1
1
|
module Danconia
|
2
2
|
module Stores
|
3
|
+
# Store implementation that persist rates using ActiveRecord.
|
3
4
|
class ActiveRecord
|
5
|
+
# @param unique_keys [Array] each save_rates will update records with this keys' values
|
6
|
+
# @param date_field [Symbol] used when storing daily rates
|
7
|
+
def initialize unique_keys: %i[pair], date_field: nil
|
8
|
+
@unique_keys = unique_keys
|
9
|
+
@date_field = date_field
|
10
|
+
end
|
11
|
+
|
12
|
+
# Creates or updates the rates by the `unique_keys` provided in the constructor.
|
13
|
+
#
|
14
|
+
# @param rates [Array] must be an array of maps.
|
4
15
|
def save_rates rates
|
5
16
|
ExchangeRate.transaction do
|
6
|
-
rates.each do |
|
7
|
-
ExchangeRate
|
17
|
+
rates.each do |fields|
|
18
|
+
ExchangeRate
|
19
|
+
.where(fields.slice(*@unique_keys))
|
20
|
+
.first_or_initialize
|
21
|
+
.update(fields.slice(*ExchangeRate.column_names.map(&:to_sym)))
|
8
22
|
end
|
9
23
|
end
|
10
24
|
end
|
11
25
|
|
12
|
-
|
13
|
-
|
26
|
+
# Returns an array of maps like the one it received.
|
27
|
+
def rates **filters
|
28
|
+
ExchangeRate.where(process_filters(filters)).map { |er| er.attributes.symbolize_keys }
|
14
29
|
end
|
15
30
|
|
16
|
-
|
17
|
-
|
31
|
+
private
|
32
|
+
|
33
|
+
def process_filters filters
|
34
|
+
if @date_field
|
35
|
+
param = filters.delete(@date_field) || Date.today
|
36
|
+
last_record = ExchangeRate.where(filters).where("#{@date_field} <= ?", param).order(@date_field => :desc).first
|
37
|
+
filters.merge(@date_field => (last_record[@date_field] if last_record))
|
38
|
+
else
|
39
|
+
filters
|
40
|
+
end
|
18
41
|
end
|
19
42
|
end
|
20
43
|
|
@@ -1,19 +1,14 @@
|
|
1
1
|
module Danconia
|
2
2
|
module Stores
|
3
3
|
class InMemory
|
4
|
-
attr_reader :rates
|
5
|
-
|
6
|
-
def initialize rates: {}
|
7
|
-
save_rates rates
|
8
|
-
end
|
9
|
-
|
10
|
-
# @rates should be of a map of pair->rate like {'USDEUR' => 1.25}
|
11
4
|
def save_rates rates
|
12
5
|
@rates = rates
|
13
6
|
end
|
14
7
|
|
15
|
-
def
|
16
|
-
@rates
|
8
|
+
def rates **filters
|
9
|
+
@rates.select do |r|
|
10
|
+
filters.all? { |k, v| r[k] == v }
|
11
|
+
end
|
17
12
|
end
|
18
13
|
end
|
19
14
|
end
|
data/lib/danconia/version.rb
CHANGED
@@ -0,0 +1,36 @@
|
|
1
|
+
require 'danconia/exchanges/bna'
|
2
|
+
|
3
|
+
module Danconia
|
4
|
+
module Exchanges
|
5
|
+
describe BNA do
|
6
|
+
subject { BNA.new }
|
7
|
+
|
8
|
+
context 'fetch_rates' do
|
9
|
+
it 'extracts the rates from the html' do
|
10
|
+
stub_request(:get, 'https://www.bna.com.ar/Personas').to_return body: fixture('home.html')
|
11
|
+
rates = subject.fetch_rates
|
12
|
+
|
13
|
+
expect(rates.select { |r| r[:rate_type] == 'billetes' }).to eq [
|
14
|
+
{pair: 'USDARS', rate: 78.25, date: Date.new(2020, 9, 1), rate_type: 'billetes'},
|
15
|
+
{pair: 'EURARS', rate: 89, date: Date.new(2020, 9, 1), rate_type: 'billetes'},
|
16
|
+
{pair: 'BRLARS', rate: 14.5, date: Date.new(2020, 9, 1), rate_type: 'billetes'}
|
17
|
+
]
|
18
|
+
|
19
|
+
expect(rates.select { |r| r[:rate_type] == 'divisas' }).to eq [
|
20
|
+
{pair: 'USDARS', rate: 74.18, date: Date.new(2020, 8, 31), rate_type: 'divisas'},
|
21
|
+
{pair: 'EURARS', rate: 88.6822, date: Date.new(2020, 8, 31), rate_type: 'divisas'}
|
22
|
+
]
|
23
|
+
end
|
24
|
+
|
25
|
+
it 'raise error if cannot parse the document' do
|
26
|
+
stub_request(:get, 'https://www.bna.com.ar/Personas').to_return body: 'some invalid html'
|
27
|
+
expect { subject.fetch_rates }.to raise_error Errors::APIError
|
28
|
+
end
|
29
|
+
|
30
|
+
def fixture file
|
31
|
+
File.read("#{__dir__}/fixtures/bna/#{file}")
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -1,55 +1,50 @@
|
|
1
|
-
require '
|
1
|
+
require 'danconia/exchanges/currency_layer'
|
2
2
|
|
3
3
|
module Danconia
|
4
4
|
module Exchanges
|
5
5
|
describe CurrencyLayer do
|
6
|
-
subject { CurrencyLayer.new access_key: '[KEY]' }
|
7
|
-
|
8
6
|
context 'fetch_rates' do
|
7
|
+
subject { CurrencyLayer.new access_key: '[KEY]' }
|
8
|
+
|
9
9
|
it 'uses the API to retrive the rates' do
|
10
|
-
stub_request(:get, 'http://www.apilayer.net/api/live?access_key=[KEY]')
|
11
|
-
|
12
|
-
|
13
|
-
"source": "USD",
|
14
|
-
"quotes": {
|
15
|
-
"USDARS": 27.110001,
|
16
|
-
"USDAUD": 1.346196
|
17
|
-
}
|
18
|
-
}
|
19
|
-
END
|
10
|
+
stub_request(:get, 'http://www.apilayer.net/api/live?access_key=[KEY]')
|
11
|
+
.to_return(body: fixture('success.json'))
|
12
|
+
|
20
13
|
expect(subject.fetch_rates).to eq 'USDARS' => 27.110001, 'USDAUD' => 1.346196
|
21
14
|
end
|
22
15
|
|
23
16
|
it 'when the API returns an error' do
|
24
|
-
stub_request(:get, 'http://www.apilayer.net/api/live?access_key=[KEY]')
|
25
|
-
|
26
|
-
|
27
|
-
"error": {
|
28
|
-
"code": 104,
|
29
|
-
"info": "Your monthly usage limit has been reached. Please upgrade your subscription plan."
|
30
|
-
}
|
31
|
-
}
|
32
|
-
END
|
17
|
+
stub_request(:get, 'http://www.apilayer.net/api/live?access_key=[KEY]')
|
18
|
+
.to_return(body: fixture('failure.json'))
|
19
|
+
|
33
20
|
expect { subject.fetch_rates }.to raise_error Errors::APIError
|
34
21
|
end
|
22
|
+
|
23
|
+
def fixture file
|
24
|
+
File.read("#{__dir__}/fixtures/currency_layer/#{file}")
|
25
|
+
end
|
35
26
|
end
|
36
27
|
|
37
28
|
context 'update_rates!' do
|
38
29
|
it 'fetches the rates and stores them' do
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
30
|
+
store = double('store')
|
31
|
+
expect(store).to receive(:save_rates).with([{pair: 'USDARS', rate: 3}, {pair: 'USDAUD', rate: 4}])
|
32
|
+
|
33
|
+
exchange = CurrencyLayer.new(access_key: '...', store: store)
|
34
|
+
allow(exchange).to receive(:fetch_rates).and_return('USDARS' => 3, 'USDAUD' => 4)
|
35
|
+
exchange.update_rates!
|
44
36
|
end
|
37
|
+
end
|
38
|
+
|
39
|
+
context 'rates' do
|
40
|
+
it 'converts the array from the store back to a map of pair to rates' do
|
41
|
+
store = double('store')
|
42
|
+
expect(store).to receive(:rates).and_return([{pair: 'USDARS', rate: 3}, {pair: 'USDAUD', rate: 4}])
|
45
43
|
|
46
|
-
|
47
|
-
|
48
|
-
expect(subject).to receive(:fetch_rates) { {'USDARS' => 3.1} }
|
49
|
-
subject.update_rates!
|
50
|
-
expect(subject.rate('USD', 'ARS')).to eq 3.1
|
44
|
+
exchange = CurrencyLayer.new(access_key: '...', store: store)
|
45
|
+
expect(exchange.rates).to eq 'USDARS' => 3, 'USDAUD' => 4
|
51
46
|
end
|
52
47
|
end
|
53
48
|
end
|
54
49
|
end
|
55
|
-
end
|
50
|
+
end
|