currency 0.1.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.
- data.tar.gz.sig +1 -0
- data/ChangeLog +5 -0
- data/README +33 -0
- data/Rakefile +349 -0
- data/Releases +6 -0
- data/TODO +0 -0
- data/examples/ex1.rb +13 -0
- data/examples/xe1.rb +19 -0
- data/lib/currency.rb +13 -0
- data/lib/currency/active_record.rb +70 -0
- data/lib/currency/core_extensions.rb +25 -0
- data/lib/currency/currency.rb +203 -0
- data/lib/currency/currency_exchange.rb +50 -0
- data/lib/currency/currency_exchange_test.rb +27 -0
- data/lib/currency/currency_exchange_xe.rb +140 -0
- data/lib/currency/currency_factory.rb +73 -0
- data/lib/currency/currency_version.rb +6 -0
- data/lib/currency/exception.rb +10 -0
- data/lib/currency/exchange_rate.rb +47 -0
- data/lib/currency/money.rb +190 -0
- data/lib/currency/money_helper.rb +12 -0
- data/scripts/gemdoc.rb +62 -0
- data/test/money_test.rb +166 -0
- data/test/test_base.rb +31 -0
- data/test/xe_test.rb +33 -0
- metadata +90 -0
- metadata.gz.sig +0 -0
@@ -0,0 +1,70 @@
|
|
1
|
+
require 'active_record/base'
|
2
|
+
require File.join(File.dirname(__FILE__), '..', 'currency')
|
3
|
+
|
4
|
+
module Currency
|
5
|
+
module ActiveRecord
|
6
|
+
def self.append_features(base) # :nodoc:
|
7
|
+
# $stderr.puts " Currency::ActiveRecord#append_features(#{base})"
|
8
|
+
super
|
9
|
+
base.extend(ClassMethods)
|
10
|
+
end
|
11
|
+
|
12
|
+
module ClassMethods
|
13
|
+
def money(attr_name, *opts)
|
14
|
+
opts = Hash.[](*opts)
|
15
|
+
|
16
|
+
attr_name = attr_name.to_s
|
17
|
+
|
18
|
+
currency = opts[:currency]
|
19
|
+
|
20
|
+
currency_field = opts[:currency_field]
|
21
|
+
if currency_field
|
22
|
+
currency = "read_attribute(:#{currency_field})"
|
23
|
+
end
|
24
|
+
|
25
|
+
currency ||= ':USD'
|
26
|
+
|
27
|
+
validate = ''
|
28
|
+
validate = "validates_numericality_of :#{attr_name}" unless opts[:allow_nil]
|
29
|
+
|
30
|
+
module_eval (x = <<-"end_eval"), __FILE__, __LINE__
|
31
|
+
#{validate}
|
32
|
+
def #{attr_name}
|
33
|
+
# $stderr.puts " \#{self.class.name}##{attr_name}"
|
34
|
+
unless @#{attr_name}
|
35
|
+
#{attr_name}_rep = read_attribute(:#{attr_name})
|
36
|
+
@#{attr_name} = Money.new_rep(#{attr_name}_rep, #{currency}) unless #{attr_name}_rep.nil?
|
37
|
+
end
|
38
|
+
@#{attr_name}
|
39
|
+
end
|
40
|
+
def #{attr_name}=(value)
|
41
|
+
if value.nil?
|
42
|
+
;
|
43
|
+
elsif value.kind_of?(Integer) || value.kind_of?(String) || value.kind_of?(Float)
|
44
|
+
#{attr_name}_money = Money.new(value, #{currency})
|
45
|
+
elsif value.kind_of?(Money)
|
46
|
+
#{attr_name}_money = value.convert(#{currency})
|
47
|
+
else
|
48
|
+
throw "Bad money format \#{value.inspect}"
|
49
|
+
end
|
50
|
+
@#{attr_name} = #{attr_name}_money
|
51
|
+
#{currency_field ? 'write_attribute(:#{currency_field}, #{attr_name}_money.nil? ? nil : #{attr_name}_money.currency.name)' : ''}
|
52
|
+
write_attribute(:#{attr_name}, #{attr_name}_money.nil? ? nil : #{attr_name}_money.rep)
|
53
|
+
value
|
54
|
+
end
|
55
|
+
def #{attr_name}_before_type_cast
|
56
|
+
x = #{attr_name}
|
57
|
+
x &&= x.format(:no_symbol, :no_currency, :no_thousands)
|
58
|
+
x
|
59
|
+
end
|
60
|
+
end_eval
|
61
|
+
# $stderr.puts " CODE = #{x}"
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
|
68
|
+
ActiveRecord::Base.class_eval do
|
69
|
+
include Currency::ActiveRecord
|
70
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
#
|
2
|
+
# External representation mixins
|
3
|
+
#
|
4
|
+
class Integer # Exact
|
5
|
+
def Money_rep(currency)
|
6
|
+
Integer(self * currency.scale)
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
|
11
|
+
class Float # Inexact
|
12
|
+
def Money_rep(currency)
|
13
|
+
Integer(self * currency.scale)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
|
18
|
+
class String # Exact
|
19
|
+
def Money_rep(currency)
|
20
|
+
x = currency.parse(self, :currency => currency)
|
21
|
+
x.rep if x.kind_of?(Currency::Money)
|
22
|
+
x
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
@@ -0,0 +1,203 @@
|
|
1
|
+
module Currency
|
2
|
+
#include Currency::Exceptions
|
3
|
+
|
4
|
+
class Currency
|
5
|
+
# Create a new currency
|
6
|
+
def initialize(code, symbol = nil, scale = 100)
|
7
|
+
self.code= code
|
8
|
+
self.symbol= symbol
|
9
|
+
self.scale= scale
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.get(code)
|
13
|
+
CurrencyFactory.default.get_by_code(code)
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.cast_code(x)
|
17
|
+
x = x.upcase.intern if x.kind_of?(String)
|
18
|
+
raise InvalidCurrencyCode.new(x) unless x.kind_of?(Symbol)
|
19
|
+
x
|
20
|
+
end
|
21
|
+
|
22
|
+
# Accessors
|
23
|
+
def code
|
24
|
+
@code
|
25
|
+
end
|
26
|
+
def code=(x)
|
27
|
+
x = self.class.cast_code(x) unless x.nil?
|
28
|
+
@code = x
|
29
|
+
#$stderr.puts "#{self}.code = #{@code}"; x
|
30
|
+
end
|
31
|
+
|
32
|
+
def scale
|
33
|
+
@scale
|
34
|
+
end
|
35
|
+
def scale=(x)
|
36
|
+
@scale = x
|
37
|
+
return x if x.nil?
|
38
|
+
@scale_exp = Integer(Math.log10(@scale));
|
39
|
+
@format_right = - @scale_exp
|
40
|
+
@format_left = @format_right - 1
|
41
|
+
x
|
42
|
+
end
|
43
|
+
|
44
|
+
def scale_exp
|
45
|
+
@scale_exp
|
46
|
+
end
|
47
|
+
|
48
|
+
def symbol
|
49
|
+
@symbol
|
50
|
+
end
|
51
|
+
def symbol=(x)
|
52
|
+
@symbol = x
|
53
|
+
end
|
54
|
+
|
55
|
+
# Parse a Money string
|
56
|
+
def parse(str, *opt)
|
57
|
+
x = str
|
58
|
+
opt = Hash.[](*opt)
|
59
|
+
|
60
|
+
md = nil # match data
|
61
|
+
|
62
|
+
# $stderr.puts "'#{x}'.Money_rep(#{self})"
|
63
|
+
|
64
|
+
# Handle currency code at front of string.
|
65
|
+
if (md = /^([A-Z][A-Z][A-Z])\s*(.*)$/.match(x)) != nil
|
66
|
+
curr = CurrencyFactory.default.get_by_code(md[1])
|
67
|
+
x = md[2]
|
68
|
+
if curr != self
|
69
|
+
if opt[:currency] && opt[:currency] != curr
|
70
|
+
raise IncompatibleCurrency.new("#{str} #{opt[:currency].code}")
|
71
|
+
end
|
72
|
+
return Money.new(x, curr);
|
73
|
+
end
|
74
|
+
# Handle currency code at end of string.
|
75
|
+
elsif (md = /^(.*)\s*([A-Z][A-Z][A-Z])$/.match(x)) != nil
|
76
|
+
curr = CurrencyFactory.default.get_by_code(md[2])
|
77
|
+
x = md[1]
|
78
|
+
if curr != self
|
79
|
+
if opt[:currency] && opt[:currency] != curr
|
80
|
+
raise IncompatibleCurrency.new("#{str} #{opt[:currency].code}")
|
81
|
+
end
|
82
|
+
return Money.new(x, curr);
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
# Remove placeholders and symbol.
|
87
|
+
x = x.gsub(/[, ]/, '')
|
88
|
+
x = x.sub(@symbol, '') if @symbol
|
89
|
+
|
90
|
+
# Match: whole Currency value.
|
91
|
+
if x =~ /^[-+]?(\d+)\.?$/
|
92
|
+
# $stderr.puts "'#{self}'.parse(#{str}) => EXACT"
|
93
|
+
x.to_i.Money_rep(self)
|
94
|
+
|
95
|
+
# Match: fractional Currency value.
|
96
|
+
elsif (md = /^([-+]?)(\d*)\.(\d+)$/.match(x)) != nil
|
97
|
+
sign = md[1]
|
98
|
+
whole = md[2]
|
99
|
+
part = md[3]
|
100
|
+
|
101
|
+
# $stderr.puts "'#{self}'.parse(#{str}) => DECIMAL (#{sign} #{whole} . #{part})"
|
102
|
+
|
103
|
+
if part.length != self.scale
|
104
|
+
|
105
|
+
# Pad decimal places with additional '0'
|
106
|
+
while part.length < self.scale_exp
|
107
|
+
part << '0'
|
108
|
+
end
|
109
|
+
|
110
|
+
# Truncate to Currency's decimal size.
|
111
|
+
part = part[0..(self.scale_exp - 1)]
|
112
|
+
|
113
|
+
# $stderr.puts " => INEXACT DECIMAL '#{whole}'"
|
114
|
+
end
|
115
|
+
|
116
|
+
# Put the string back together:
|
117
|
+
# #{sign}#{whole}#{part}
|
118
|
+
whole = sign + whole + part
|
119
|
+
# $stderr.puts " => REP = #{whole}"
|
120
|
+
|
121
|
+
x = whole.to_i
|
122
|
+
|
123
|
+
x = Money.new_rep(x, opt[:currency])
|
124
|
+
else
|
125
|
+
# $stderr.puts "'#{self}'.parse(#{str}) => ??? '#{x}'"
|
126
|
+
#x.to_f.Money_rep(self)
|
127
|
+
raise InvalidMoneyString.new("#{str} #{self}")
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
# Format a Money object.
|
132
|
+
def format(m, *opt)
|
133
|
+
opt = opt.flatten
|
134
|
+
|
135
|
+
# Get scaled integer representation for this Currency.
|
136
|
+
x = m.Money_rep(self)
|
137
|
+
|
138
|
+
# Remove sign.
|
139
|
+
x = - x if ( neg = x < 0 )
|
140
|
+
|
141
|
+
# Convert to String.
|
142
|
+
x = x.to_s
|
143
|
+
|
144
|
+
# Keep prefixing "0" until filled to scale.
|
145
|
+
while ( x.length <= @scale_exp )
|
146
|
+
x = "0" + x
|
147
|
+
end
|
148
|
+
|
149
|
+
# Insert decimal place
|
150
|
+
whole = x[0..@format_left]
|
151
|
+
decimal = x[@format_right..-1]
|
152
|
+
|
153
|
+
# Do commas
|
154
|
+
x = whole
|
155
|
+
unless opt.include?(:no_thousands)
|
156
|
+
x.reverse!
|
157
|
+
x.gsub!(/(\d\d\d)/) {|y| y + ','}
|
158
|
+
x.sub!(/,$/,'')
|
159
|
+
x.reverse!
|
160
|
+
end
|
161
|
+
|
162
|
+
x << '.' + decimal unless opt.include?(:no_cents) || opt.include?(:no_decimal)
|
163
|
+
|
164
|
+
# Put sign back.
|
165
|
+
x = "-" + x if neg
|
166
|
+
|
167
|
+
# Add symbol?
|
168
|
+
x = @symbol + x unless opt.include?(:no_symbol)
|
169
|
+
|
170
|
+
# Suffix currency code.
|
171
|
+
if opt.include?(:with_currency)
|
172
|
+
x << ' '
|
173
|
+
x << '<span class="currency">' if opt.include?(:html)
|
174
|
+
x << @code.to_s
|
175
|
+
x << '</span>' if opt.include?(:html)
|
176
|
+
end
|
177
|
+
|
178
|
+
x
|
179
|
+
end
|
180
|
+
|
181
|
+
#def to_s
|
182
|
+
# @code.to_s
|
183
|
+
#end
|
184
|
+
|
185
|
+
# Convience
|
186
|
+
# Default currency
|
187
|
+
def self.default
|
188
|
+
CurrencyFactory.default.currency
|
189
|
+
end
|
190
|
+
|
191
|
+
def self.USD
|
192
|
+
CurrencyFactory.default.USD
|
193
|
+
end
|
194
|
+
|
195
|
+
def self.CAD
|
196
|
+
CurrencyFactory.default.CAD
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
|
201
|
+
# END MODULE
|
202
|
+
end
|
203
|
+
|
@@ -0,0 +1,50 @@
|
|
1
|
+
module Currency
|
2
|
+
# Represents a method of converting between two currencies
|
3
|
+
# TODO:
|
4
|
+
# Create an ExchangeRateLoader class.
|
5
|
+
# Create an ExchangeRateLoader subclass that interfaces to xe.com or other FX quote source.
|
6
|
+
class CurrencyExchange
|
7
|
+
@@default = nil
|
8
|
+
def self.default
|
9
|
+
@@default ||= self.new
|
10
|
+
end
|
11
|
+
def self.default=(x)
|
12
|
+
@@default = x
|
13
|
+
end
|
14
|
+
|
15
|
+
def initialize(*opt)
|
16
|
+
@exchange_rate = { }
|
17
|
+
end
|
18
|
+
|
19
|
+
def convert(m, c2, c1 = nil)
|
20
|
+
c1 = m.currency if c1 == nil
|
21
|
+
if ( c1 == c2 )
|
22
|
+
m
|
23
|
+
else
|
24
|
+
Money.new(exchange_rate(c1, c2).convert(m, c1), c2)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def clear_exchange_rates
|
29
|
+
@exchange_rate.empty!
|
30
|
+
end
|
31
|
+
|
32
|
+
def clear_exchange_rate(c1, c2, recip = true)
|
33
|
+
@exchange_rate[c1.code.to_s + c2.code.to_s] = nil
|
34
|
+
@exchange_rate[c2.code.to_s + c1.code.to_s] = nil if recip
|
35
|
+
end
|
36
|
+
|
37
|
+
def exchange_rate(c1, c2)
|
38
|
+
(@exchange_rate[c1.code.to_s + c2.code.to_s] ||= load_exchange_rate(c1, c2)) ||
|
39
|
+
(@exchange_rate[c2.code.to_s + c1.code.to_s] ||= load_exchange_rate(c2, c1))
|
40
|
+
end
|
41
|
+
|
42
|
+
|
43
|
+
def load_exchange_rate(c1, c2)
|
44
|
+
raise "Subclass responsibility: load_exchange_rate"
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
# END MODULE
|
49
|
+
end
|
50
|
+
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module Currency
|
2
|
+
# This can convert only between USD and CAD
|
3
|
+
class CurrencyExchangeTest < CurrencyExchange
|
4
|
+
@@instance = nil
|
5
|
+
def self.instance(*opts)
|
6
|
+
@@instance ||= self.new(*opts)
|
7
|
+
end
|
8
|
+
|
9
|
+
# Sample constant.
|
10
|
+
def self.USD_CAD; 1.1708; end
|
11
|
+
|
12
|
+
def load_exchange_rate(c1, c2)
|
13
|
+
# $stderr.puts "load_exchange_rate(#{c1}, #{c2})"
|
14
|
+
rate = 0.0
|
15
|
+
if ( c1.code == :USD && c2.code == :CAD )
|
16
|
+
rate = self.class.USD_CAD
|
17
|
+
end
|
18
|
+
rate > 0 ? ExchangeRate.new(c1, c2, rate, self.class.name) : nil
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
# END MODULE
|
23
|
+
end
|
24
|
+
|
25
|
+
# Install as current
|
26
|
+
Currency::CurrencyExchange.default = Currency::CurrencyExchangeTest.instance
|
27
|
+
|
@@ -0,0 +1,140 @@
|
|
1
|
+
require 'net/http'
|
2
|
+
require 'open-uri'
|
3
|
+
|
4
|
+
module Currency
|
5
|
+
# Represents connects to http://xe.com and groks "XE.com Quick Cross Rates"
|
6
|
+
|
7
|
+
class CurrencyExchangeXe < CurrencyExchange
|
8
|
+
@@instance = nil
|
9
|
+
def self.instance(*opts)
|
10
|
+
@@instance ||= self.new(*opts)
|
11
|
+
end
|
12
|
+
|
13
|
+
attr_accessor :uri
|
14
|
+
|
15
|
+
def initialize(*opt)
|
16
|
+
super(*opt)
|
17
|
+
self.uri = 'http://xe.com/'
|
18
|
+
@rates = nil
|
19
|
+
end
|
20
|
+
|
21
|
+
def rates
|
22
|
+
return @rates if @rates
|
23
|
+
|
24
|
+
@rates = parse_page_rates
|
25
|
+
return @rates unless @rates
|
26
|
+
|
27
|
+
@rates_usd_cur = @rates[:USD]
|
28
|
+
@rate_timestamp = Time.now
|
29
|
+
|
30
|
+
@rates
|
31
|
+
end
|
32
|
+
|
33
|
+
def get_page
|
34
|
+
data = open(uri) { |data| data.read }
|
35
|
+
|
36
|
+
data = data.split(/[\r\n]/)
|
37
|
+
|
38
|
+
data
|
39
|
+
end
|
40
|
+
|
41
|
+
def parse_page_rates(data = nil)
|
42
|
+
data = get_page unless data
|
43
|
+
|
44
|
+
# Chomp after
|
45
|
+
until data.empty?
|
46
|
+
line = data.pop
|
47
|
+
break if line =~ /Need More currencies\?/
|
48
|
+
end
|
49
|
+
|
50
|
+
# Chomp before
|
51
|
+
until data.empty?
|
52
|
+
line = data.shift
|
53
|
+
break if line =~ /XE.com Quick Cross Rates/
|
54
|
+
end
|
55
|
+
|
56
|
+
until data.empty?
|
57
|
+
line = data.shift
|
58
|
+
break if line =~ /Confused about how to use the rates/i
|
59
|
+
end
|
60
|
+
|
61
|
+
until data.empty?
|
62
|
+
line = data.shift
|
63
|
+
break if line =~ /^\s*<\/tr>/i
|
64
|
+
end
|
65
|
+
# $stderr.puts "#{data[0..4].inspect}"
|
66
|
+
|
67
|
+
# Read first table row to get position for each currency
|
68
|
+
currency = [ ]
|
69
|
+
until data.empty?
|
70
|
+
line = data.shift
|
71
|
+
break if line =~ /^\s*<\/tr>/i
|
72
|
+
if md = /<td><IMG .+ ALT="([A-Z][A-Z][A-Z])"/i.match(line) #"
|
73
|
+
cur = md[1].intern
|
74
|
+
cur_i = currency.size
|
75
|
+
currency.push(cur)
|
76
|
+
# $stderr.puts "Found currency header: #{cur.inspect} at #{cur_i}"
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
# $stderr.puts "#{data[0..4].inspect}"
|
81
|
+
|
82
|
+
# Skip blank <tr>
|
83
|
+
until data.empty?
|
84
|
+
line = data.shift
|
85
|
+
break if line =~ /^\s*<td>.+1.+USD.+=/
|
86
|
+
end
|
87
|
+
|
88
|
+
until data.empty?
|
89
|
+
line = data.shift
|
90
|
+
break if line =~ /^\s*<\/tr>/i
|
91
|
+
end
|
92
|
+
|
93
|
+
# $stderr.puts "#{data[0..4].inspect}"
|
94
|
+
|
95
|
+
# Read first row of 1 USD = ...
|
96
|
+
|
97
|
+
rate = { }
|
98
|
+
cur_i = -1
|
99
|
+
until data.empty?
|
100
|
+
line = data.shift
|
101
|
+
break if cur_i < 0 && line =~ /^\s*<\/tr>/i
|
102
|
+
if md = /<td>\s+(\d+\.\d+)\s+<\/td>/.match(line)
|
103
|
+
usd_to_cur = md[1].to_f
|
104
|
+
cur_i = cur_i + 1
|
105
|
+
cur = currency[cur_i]
|
106
|
+
(rate[:USD] ||= {})[cur] = usd_to_cur
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
rate
|
111
|
+
end
|
112
|
+
|
113
|
+
def load_exchange_rate(c1, c2)
|
114
|
+
rates # Load rates
|
115
|
+
|
116
|
+
# $stderr.puts "load_exchange_rate(#{c1}, #{c2})"
|
117
|
+
rate = 0.0
|
118
|
+
r1 = nil
|
119
|
+
r2 = nil
|
120
|
+
|
121
|
+
if ( c1.code == :USD && (r2 = @rates_usd_cur[c2.code]) )
|
122
|
+
rate = r2
|
123
|
+
elsif ( c2.code == :USD && (r1 = @rates_usd_cur[c2.code]) )
|
124
|
+
rate = 1.0 / r1
|
125
|
+
elsif ( (r1 = @rates_usd_cur[c1.code]) && (r2 = @rates_usd_cur[c2.code]) )
|
126
|
+
rate = r2 / r1
|
127
|
+
end
|
128
|
+
|
129
|
+
# $stderr.puts "XE Rate: #{c1.code} / #{c2.code} = #{rate}"
|
130
|
+
|
131
|
+
rate > 0 ? ExchangeRate.new(c1, c2, rate, self.class.name, @rate_timestamp) : nil
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
# END MODULE
|
136
|
+
end
|
137
|
+
|
138
|
+
# Install as current
|
139
|
+
Currency::CurrencyExchange.default = Currency::CurrencyExchangeXe.instance
|
140
|
+
|