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.
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