danconia 0.2.9 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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,23 +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
44
+ amount <=> amount_exchanged_to_this_currency(other)
46
45
  end
47
46
 
48
- def exchange_to other_currency, exchange: @exchange
49
- other_currency = other_currency.presence && Currency.find(other_currency, exchange) || currency
50
- rate = exchange.rate currency.code, other_currency.code
51
- clone_with amount * rate, other_currency, exchange
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
52
52
  end
53
53
 
54
- %w(+ - * /).each do |op|
55
- class_eval <<-EOR, __FILE__, __LINE__ + 1
54
+ %w[+ - * /].each do |op|
55
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
56
56
  def #{op} other
57
- other = other.exchange_to(currency, exchange: @exchange).amount if other.is_a? Money
58
- clone_with(amount #{op} other)
57
+ clone_with(amount #{op} amount_exchanged_to_this_currency(other))
59
58
  end
60
- EOR
59
+ RUBY
61
60
  end
62
61
 
63
62
  def round *args
@@ -94,8 +93,16 @@ module Danconia
94
93
  BigDecimal(object.to_s) rescue BigDecimal(0)
95
94
  end
96
95
 
97
- def clone_with amount, currency = @currency, exchange = @exchange
98
- 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
99
106
  end
100
107
  end
101
108
  end
@@ -1,16 +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 rates
13
- Hash[ExchangeRate.all.map { |er| [er.pair, er.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 }
29
+ end
30
+
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
14
41
  end
15
42
  end
16
43
 
@@ -1,16 +1,15 @@
1
1
  module Danconia
2
2
  module Stores
3
3
  class InMemory
4
- attr_reader :rates
5
-
6
- # `rates` should be of a map of pair->rate like {'USDEUR' => 1.25}
7
- def initialize rates: {}
8
- save_rates rates
9
- end
10
-
11
4
  def save_rates rates
12
5
  @rates = rates
13
6
  end
7
+
8
+ def rates **filters
9
+ @rates.select do |r|
10
+ filters.all? { |k, v| r[k] == v }
11
+ end
12
+ end
14
13
  end
15
14
  end
16
15
  end
@@ -1,3 +1,3 @@
1
1
  module Danconia
2
- VERSION = '0.2.9'
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,41 +1,49 @@
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: fixture('success.json')
10
+ stub_request(:get, 'http://www.apilayer.net/api/live?access_key=[KEY]')
11
+ .to_return(body: fixture('success.json'))
12
+
11
13
  expect(subject.fetch_rates).to eq 'USDARS' => 27.110001, 'USDAUD' => 1.346196
12
14
  end
13
15
 
14
16
  it 'when the API returns an error' do
15
- stub_request(:get, 'http://www.apilayer.net/api/live?access_key=[KEY]').to_return body: fixture('failure.json')
17
+ stub_request(:get, 'http://www.apilayer.net/api/live?access_key=[KEY]')
18
+ .to_return(body: fixture('failure.json'))
19
+
16
20
  expect { subject.fetch_rates }.to raise_error Errors::APIError
17
21
  end
22
+
23
+ def fixture file
24
+ File.read("#{__dir__}/fixtures/currency_layer/#{file}")
25
+ end
18
26
  end
19
27
 
20
28
  context 'update_rates!' do
21
29
  it 'fetches the rates and stores them' do
22
- expect(subject).to receive(:fetch_rates) { {'USDARS' => 3, 'USDAUD' => 4} }
23
- subject.update_rates!
24
- expect(subject.store.rates.size).to eq 2
25
- expect(subject.rate('USD', 'ARS')).to eq 3
26
- expect(subject.rate('USD', 'AUD')).to eq 4
27
- end
30
+ store = double('store')
31
+ expect(store).to receive(:save_rates).with([{pair: 'USDARS', rate: 3}, {pair: 'USDAUD', rate: 4}])
28
32
 
29
- it 'if a rate already exists should update it' do
30
- subject.store.save_rates 'USDARS' => 3
31
- expect(subject).to receive(:fetch_rates) { {'USDARS' => 3.1} }
32
- subject.update_rates!
33
- expect(subject.rate('USD', 'ARS')).to eq 3.1
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!
34
36
  end
35
37
  end
36
38
 
37
- def fixture file
38
- File.read("#{__dir__}/fixtures/currency_layer/#{file}")
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}])
43
+
44
+ exchange = CurrencyLayer.new(access_key: '...', store: store)
45
+ expect(exchange.rates).to eq 'USDARS' => 3, 'USDAUD' => 4
46
+ end
39
47
  end
40
48
  end
41
49
  end
@@ -1,5 +1,3 @@
1
- require 'spec_helper'
2
-
3
1
  module Danconia
4
2
  module Exchanges
5
3
  describe Exchange do
@@ -30,7 +28,21 @@ module Danconia
30
28
  end
31
29
 
32
30
  it 'raises an error if the conversion cannot be made' do
33
- expect { subject.rate('USD', 'EUR') }.to raise_error Errors::ExchangeRateNotFound
31
+ expect { fake_exchange({}).rate('USD', 'EUR') }.to raise_error Errors::ExchangeRateNotFound
32
+ end
33
+
34
+ it 'should allow to pass options to filter the rates' do
35
+ exchange = Class.new(Exchange) do
36
+ def rates type:
37
+ case type
38
+ when 'divisa' then {'USDARS' => 7}
39
+ when 'billete' then {'USDARS' => 8}
40
+ end
41
+ end
42
+ end.new
43
+
44
+ expect(exchange.rate('USD', 'ARS', type: 'divisa')).to eq 7
45
+ expect(exchange.rate('USD', 'ARS', type: 'billete')).to eq 8
34
46
  end
35
47
 
36
48
  def fake_exchange(rates)
@@ -0,0 +1,124 @@
1
+ <!DOCTYPE html>
2
+ <html lang="es" class="pc">
3
+
4
+ <body>
5
+ <div id="rightHome">
6
+ <div class="col-md-3">
7
+ <div class="tabSmall">
8
+ <ul class="nav nav-tabs">
9
+
10
+ <li class="active"><a href="#billetes" data-toggle="tab">Cotización Billetes</a>
11
+ <div class="arrow"></div>
12
+ </li>
13
+ <li><a href="#divisas" data-toggle="tab">Cotización Divisas</a>
14
+ <div class="arrow"></div>
15
+ </li>
16
+ </ul>
17
+
18
+ <div class="tab-content">
19
+
20
+ <div class="tab-pane fade in active" id="billetes">
21
+
22
+ <table class="table cotizacion">
23
+ <thead>
24
+ <tr>
25
+ <th class="fechaCot">1/9/2020</th>
26
+ <th>Compra</th>
27
+ <th>Venta</th>
28
+ </tr>
29
+ </thead>
30
+ <tbody>
31
+
32
+ <tr>
33
+ <td class="tit">Dolar U.S.A</td>
34
+ <td>73,2500</td>
35
+ <td>78,2500</td>
36
+ </tr>
37
+ <tr>
38
+ <td class="tit">Euro</td>
39
+ <td>84,0000</td>
40
+ <td>89,0000</td>
41
+ </tr>
42
+ <tr>
43
+ <td class="tit">Real *</td>
44
+ <td>1250,0000</td>
45
+ <td>1450,0000</td>
46
+ </tr>
47
+
48
+ </tbody>
49
+ </table>
50
+ <a href="#" class="link-cotizacion" data-toggle="modal" data-target="#modalHistorico" id="buttonHistoricoBilletes">Ver histórico</a>
51
+ <div class="legal">Hora Actualización: 10:40</div>
52
+ <div class="legal">(*) cotización cada 100 unidades.</div>
53
+
54
+ </div>
55
+
56
+ <div class="tab-pane fade" id="divisas">
57
+ <table class="table cotizacion">
58
+ <thead>
59
+ <tr>
60
+ <th class="fechaCot">31/8/2020</th>
61
+ <th>Compra</th>
62
+ <th>Venta</th>
63
+ </tr>
64
+ </thead>
65
+ <tbody>
66
+
67
+ <tr>
68
+ <td class="tit">Dolar U.S.A</td>
69
+ <td>73.9800</td>
70
+ <td>74.1800</td>
71
+ </tr>
72
+ <tr>
73
+ <td class="tit">Libra Esterlina</td>
74
+ <td>98.8743</td>
75
+ <td>99.3641</td>
76
+ </tr>
77
+ <tr>
78
+ <td class="tit">Euro</td>
79
+ <td>88.2581</td>
80
+ <td>88.6822</td>
81
+ </tr>
82
+ <tr>
83
+ <td class="tit">Franco Suizos *</td>
84
+ <td>8188.4905</td>
85
+ <td>8221.8519</td>
86
+ </tr>
87
+ <tr>
88
+ <td class="tit">YENES *</td>
89
+ <td>69.8238</td>
90
+ <td>70.1124</td>
91
+ </tr>
92
+ <tr>
93
+ <td class="tit">Dolares Canadienses *</td>
94
+ <td>5675.3160</td>
95
+ <td>5698.6457</td>
96
+ </tr>
97
+ <tr>
98
+ <td class="tit">Coronas Danesas *</td>
99
+ <td>1184.4394</td>
100
+ <td>1195.0806</td>
101
+ </tr>
102
+ <tr>
103
+ <td class="tit">Coronas Noruegas *</td>
104
+ <td>845.1963</td>
105
+ <td>854.8381</td>
106
+ </tr>
107
+ <tr>
108
+ <td class="tit">Coronas Suecas *</td>
109
+ <td>853.8596</td>
110
+ <td>863.8369</td>
111
+ </tr>
112
+
113
+ </tbody>
114
+ </table>
115
+ <a href="#" class="link-cotizacion" data-toggle="modal" data-target="#modalHistorico" id="buttonHistoricoMonedas">Ver histórico</a>
116
+ <div class="legal">(*) cotización cada 100 unidades.</div>
117
+ <div class="legal leyenda">El tipo de cambio de cierre de divisa es suministrado al público a fines informativos, como referencia de la cotización de la divisa en el mercado mayorista al final de cada día.</div>
118
+ </div>
119
+ </div>
120
+ </div>
121
+ </div>
122
+ </body>
123
+
124
+ </html>