danconia 0.2.9 → 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.
@@ -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>