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.
- checksums.yaml +4 -4
- data/.rubocop.yml +104 -0
- data/Gemfile.lock +25 -2
- data/README.md +16 -2
- data/danconia.gemspec +5 -1
- data/examples/bna.rb +35 -0
- data/examples/currency_layer.rb +7 -2
- data/examples/single_currency.rb +3 -2
- 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 -18
- data/lib/danconia/stores/active_record.rb +31 -4
- data/lib/danconia/stores/in_memory.rb +6 -7
- data/lib/danconia/version.rb +1 -1
- data/spec/danconia/exchanges/bna_spec.rb +36 -0
- data/spec/danconia/exchanges/currency_layer_spec.rb +26 -18
- data/spec/danconia/exchanges/exchange_spec.rb +15 -3
- data/spec/danconia/exchanges/fixtures/bna/home.html +124 -0
- data/spec/danconia/integrations/active_record_spec.rb +25 -5
- data/spec/danconia/money_spec.rb +41 -11
- data/spec/danconia/stores/active_record_spec.rb +79 -13
- data/spec/danconia/stores/in_memory_spec.rb +18 -0
- data/spec/spec_helper.rb +2 -1
- metadata +57 -6
- data/lib/danconia/exchanges/exchange.rb +0 -47
@@ -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,23 +41,22 @@ module Danconia
|
|
41
41
|
end
|
42
42
|
|
43
43
|
def <=> other
|
44
|
-
|
45
|
-
amount <=> other
|
44
|
+
amount <=> amount_exchanged_to_this_currency(other)
|
46
45
|
end
|
47
46
|
|
48
|
-
def exchange_to other_currency,
|
49
|
-
|
50
|
-
|
51
|
-
|
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
|
55
|
-
class_eval <<-
|
54
|
+
%w[+ - * /].each do |op|
|
55
|
+
class_eval <<-RUBY, __FILE__, __LINE__ + 1
|
56
56
|
def #{op} other
|
57
|
-
|
58
|
-
clone_with(amount #{op} other)
|
57
|
+
clone_with(amount #{op} amount_exchanged_to_this_currency(other))
|
59
58
|
end
|
60
|
-
|
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,
|
98
|
-
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
|
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 |
|
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 }
|
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
|
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,41 +1,49 @@
|
|
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]')
|
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]')
|
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
|
-
|
23
|
-
|
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
|
-
|
30
|
-
|
31
|
-
|
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
|
-
|
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}])
|
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 {
|
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>
|