danconia 0.2.5 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +104 -0
  3. data/.travis.yml +4 -0
  4. data/Gemfile.lock +40 -17
  5. data/README.md +16 -2
  6. data/bin/console +7 -0
  7. data/danconia.gemspec +5 -1
  8. data/examples/bna.rb +35 -0
  9. data/examples/currency_layer.rb +8 -4
  10. data/examples/fixed_rates.rb +1 -3
  11. data/examples/single_currency.rb +2 -3
  12. data/lib/danconia.rb +6 -4
  13. data/lib/danconia/currency.rb +2 -2
  14. data/lib/danconia/exchange.rb +47 -0
  15. data/lib/danconia/exchanges/bna.rb +61 -0
  16. data/lib/danconia/exchanges/currency_layer.rb +14 -2
  17. data/lib/danconia/exchanges/fixed_rates.rb +2 -4
  18. data/lib/danconia/integrations/active_record.rb +13 -14
  19. data/lib/danconia/money.rb +25 -24
  20. data/lib/danconia/pair.rb +15 -0
  21. data/lib/danconia/stores/active_record.rb +29 -6
  22. data/lib/danconia/stores/in_memory.rb +4 -9
  23. data/lib/danconia/version.rb +1 -1
  24. data/spec/danconia/exchanges/bna_spec.rb +36 -0
  25. data/spec/danconia/exchanges/currency_layer_spec.rb +28 -33
  26. data/spec/danconia/exchanges/exchange_spec.rb +54 -0
  27. data/spec/danconia/exchanges/fixtures/bna/home.html +124 -0
  28. data/spec/danconia/exchanges/fixtures/currency_layer/failure.json +7 -0
  29. data/spec/danconia/exchanges/fixtures/currency_layer/success.json +8 -0
  30. data/spec/danconia/integrations/active_record_spec.rb +25 -5
  31. data/spec/danconia/money_spec.rb +57 -15
  32. data/spec/danconia/stores/active_record_spec.rb +81 -15
  33. data/spec/danconia/stores/in_memory_spec.rb +18 -0
  34. data/spec/spec_helper.rb +2 -1
  35. metadata +67 -9
  36. data/lib/danconia/exchanges/exchange.rb +0 -31
  37. 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
- def initialize access_key:, **args
9
- super args
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,13 +1,11 @@
1
1
  module Danconia
2
2
  module Exchanges
3
3
  class FixedRates < Exchange
4
- def initialize rates: {}, **args
5
- super args
4
+ def initialize rates: {}
6
5
  @rates = rates
7
- update_rates!
8
6
  end
9
7
 
10
- def fetch_rates
8
+ def rates **_opts
11
9
  @rates
12
10
  end
13
11
  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
- class_eval <<-EOR, __FILE__, __LINE__ + 1
17
- def #{attr_name}= 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
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
- def #{attr_name}
23
- amount = read_attribute :#{amount_column}
24
- currency = read_attribute :#{currency_column}
25
- decimals = self.class.columns.detect { |c| c.name == '#{amount_column}' }.scale
26
- Money.new amount, currency, decimals: decimals if amount
27
- end
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
@@ -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, :exchange
7
+ attr_reader :amount, :currency, :decimals
8
8
 
9
- def initialize amount, currency_code = nil, decimals: 2, exchange: Danconia.config.default_exchange
10
- @decimals = decimals
9
+ def initialize(amount, currency_code = nil, decimals: 2, exchange_opts: {})
11
10
  @amount = parse amount
12
- @currency = Currency.find(currency_code || Danconia.config.default_currency, exchange)
13
- @exchange = exchange
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
- other = other.exchange_to(currency).amount if other.is_a? Money
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 exchange_rate_to to
55
- from = currency.code
56
- return 1 if from == to
57
- exchange.rate from, to
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(+ - * /).each do |op|
61
- class_eval <<-EOR, __FILE__, __LINE__ + 1
54
+ %w[+ - * /].each do |op|
55
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
62
56
  def #{op} other
63
- other = other.exchange_to(currency).amount if other.is_a? Money
64
- clone_with amount #{op} other
57
+ clone_with(amount #{op} amount_exchanged_to_this_currency(other))
65
58
  end
66
- EOR
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, exchange: exchange
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
@@ -0,0 +1,15 @@
1
+ module Danconia
2
+ Pair = Struct.new(:from, :to) do
3
+ def self.parse str
4
+ new str[0..2], str[3..-1]
5
+ end
6
+
7
+ def invert
8
+ Pair.new to, from
9
+ end
10
+
11
+ def to_s
12
+ [from, to].join
13
+ end
14
+ end
15
+ 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 |pair, rate|
7
- ExchangeRate.where(pair: pair).first_or_initialize.update rate: rate
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
- def direct_rate from, to
13
- ExchangeRate.find_by(pair: [from, to].join)&.rate
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
- def rates
17
- ExchangeRate.all
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 direct_rate from, to
16
- @rates[[from, to].join]
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
@@ -1,3 +1,3 @@
1
1
  module Danconia
2
- VERSION = '0.2.5'
2
+ VERSION = '0.3.0'
3
3
  end
@@ -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 'spec_helper'
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]').to_return body: <<~END
11
- {
12
- "success": true,
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]').to_return body: <<~END
25
- {
26
- "success": false,
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
- expect(subject).to receive(:fetch_rates) { {'USDARS' => 3, 'USDAUD' => 4} }
40
- subject.update_rates!
41
- expect(subject.rates.size).to eq 2
42
- expect(subject.rate('USD', 'ARS')).to eq 3
43
- expect(subject.rate('USD', 'AUD')).to eq 4
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
- it 'if a rate already exists should update it' do
47
- subject.store.save_rates 'USDARS' => 3
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