monies 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE.txt +4 -0
- data/README.md +270 -0
- data/lib/monies/digits.rb +63 -0
- data/lib/monies/format.rb +55 -0
- data/lib/monies/parser.rb +109 -0
- data/lib/monies/serialization/active_record.rb +53 -0
- data/lib/monies/serialization/sequel.rb +67 -0
- data/lib/monies/serialization.rb +58 -0
- data/lib/monies/symbols.rb +45 -0
- data/lib/monies.rb +493 -0
- data/monies.gemspec +19 -0
- metadata +57 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 457a633b330a9b30ecebac6694ed2b29566e96ed3db33b3ba2a8feca86501c88
|
4
|
+
data.tar.gz: 9b3fe5366065d52108a6b3b6d74f0daba814174f47063ce12dfac75d10ecb132
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: ef772a5e785576896d10adc3638aa7062f9e148cf6678394c82e2d5f1555ea486822cc1bc018b3babc3c3976480d6fb8b7fff15bf4c315861b175c4f996ffbf2
|
7
|
+
data.tar.gz: a69ffff9d64b66179c71bdeb7d4443a594818523e7d54ba845d3ad7cebf9f580cdd86173be0a7cb68a5ed4078262946620f8908fbc2c58a86254e60a60ae248e
|
data/LICENSE.txt
ADDED
data/README.md
ADDED
@@ -0,0 +1,270 @@
|
|
1
|
+
# monies
|
2
|
+
|
3
|
+
Ruby gem for representing monetary values.
|
4
|
+
|
5
|
+
Pure Ruby—compatible with MRI/CRuby, JRuby, TruffleRuby, and Natalie.
|
6
|
+
|
7
|
+
|
8
|
+
## Installation
|
9
|
+
|
10
|
+
Using Bundler:
|
11
|
+
|
12
|
+
$ bundle add monies
|
13
|
+
|
14
|
+
Using RubyGems:
|
15
|
+
|
16
|
+
$ gem install monies
|
17
|
+
|
18
|
+
|
19
|
+
## Usage
|
20
|
+
|
21
|
+
Getting started:
|
22
|
+
|
23
|
+
```ruby
|
24
|
+
require 'monies'
|
25
|
+
|
26
|
+
a = Monies(11, 'USD')
|
27
|
+
b = Monies('22.22', 'USD') * 2
|
28
|
+
c = Monies.parse('$33.44')
|
29
|
+
|
30
|
+
puts Monies.format(a + b + c, symbol: true)
|
31
|
+
```
|
32
|
+
|
33
|
+
|
34
|
+
## Currencies
|
35
|
+
|
36
|
+
Currencies are represented as strings. Using [ISO 4217 currency codes](https://en.wikipedia.org/wiki/ISO_4217)
|
37
|
+
is recommended for maximum interoperability, however there are no restrictions
|
38
|
+
on what strings you can use. For example:
|
39
|
+
|
40
|
+
```ruby
|
41
|
+
Monies(10, 'USD')
|
42
|
+
Monies(10, 'BTC')
|
43
|
+
Monies(10, 'GBX')
|
44
|
+
Monies(10, 'X')
|
45
|
+
Monies(10, 'LOL')
|
46
|
+
Monies(10, 'Cubit')
|
47
|
+
Monies(10, 'Latinum')
|
48
|
+
Monies(10, 'sats')
|
49
|
+
```
|
50
|
+
|
51
|
+
If your application primarily uses a single currency you can set a default currency:
|
52
|
+
|
53
|
+
```ruby
|
54
|
+
Monies.currency = 'USD'
|
55
|
+
|
56
|
+
Monies(10)
|
57
|
+
```
|
58
|
+
|
59
|
+
|
60
|
+
## Arithmetic
|
61
|
+
|
62
|
+
Arithmetic is currency checked to prevent errors:
|
63
|
+
|
64
|
+
```ruby
|
65
|
+
Monies(1, 'USD') + Monies(1, 'RUB') # raises Monies::CurrencyError
|
66
|
+
```
|
67
|
+
|
68
|
+
If you need to sum amounts in different currencies you should first convert
|
69
|
+
them all to the same currency.
|
70
|
+
|
71
|
+
Division is limited to 16 decimal places by default, which ought to be enough
|
72
|
+
for everyone. If you need more accuracy you can use the #div method to specify
|
73
|
+
the maximum number of decimal places you want:
|
74
|
+
|
75
|
+
```ruby
|
76
|
+
Monies(1, 'USD').div(9, 100)
|
77
|
+
```
|
78
|
+
|
79
|
+
|
80
|
+
## Currency conversion
|
81
|
+
|
82
|
+
Use the #convert method to convert instances to another currency:
|
83
|
+
|
84
|
+
```ruby
|
85
|
+
value = Monies('1.23', 'BTC')
|
86
|
+
|
87
|
+
price = Monies(100_000, 'USD')
|
88
|
+
|
89
|
+
puts value.convert(price).round(2)
|
90
|
+
```
|
91
|
+
|
92
|
+
Fetching price data, caching that data, and rounding the result are all
|
93
|
+
responsibilities of the caller.
|
94
|
+
|
95
|
+
|
96
|
+
## Parsing strings
|
97
|
+
|
98
|
+
Use the `Monies` method to convert strings that are expected to be valid and
|
99
|
+
don't contain special formatting, such as those in source code and databases:
|
100
|
+
|
101
|
+
```ruby
|
102
|
+
Monies('12345.6')
|
103
|
+
```
|
104
|
+
|
105
|
+
Use the `Monies.parse` method to parse strings that could be invalid or could
|
106
|
+
contain special formatting like thousand separators, currency codes, or symbols:
|
107
|
+
|
108
|
+
```ruby
|
109
|
+
Monies.parse('£1,999.99')
|
110
|
+
Monies.parse('1.999,00 EUR')
|
111
|
+
```
|
112
|
+
|
113
|
+
An `ArgumentError` exception is raised for invalid input:
|
114
|
+
|
115
|
+
```ruby
|
116
|
+
Monies.parse('notmoney') # raises ArgumentError
|
117
|
+
```
|
118
|
+
|
119
|
+
Currency symbols and currency codes are defined in `Monies.symbols` which can
|
120
|
+
be updated to support additional currencies using #[]= or #update. For example:
|
121
|
+
|
122
|
+
```ruby
|
123
|
+
Monies.symbols["\u20BF"] = 'BTC'
|
124
|
+
|
125
|
+
Monies.symbols.update({"\u20BF" => 'BTC'})
|
126
|
+
|
127
|
+
Monies.parse('1,000 BTC')
|
128
|
+
```
|
129
|
+
|
130
|
+
|
131
|
+
## Formatting strings
|
132
|
+
|
133
|
+
Use the `Monies.format` method to produce formatted strings:
|
134
|
+
|
135
|
+
```ruby
|
136
|
+
Monies.format(Monies('1234.56', 'USD')) # "1,234.56"
|
137
|
+
```
|
138
|
+
|
139
|
+
Specify the `code` or `symbol` options to include the currency code or symbol:
|
140
|
+
|
141
|
+
```ruby
|
142
|
+
Monies.format(Monies('1234.56', 'USD'), code: true) # "1,234.56 USD"
|
143
|
+
|
144
|
+
Monies.format(Monies('1234.56', 'USD'), symbol: true) # "$1,234.56"
|
145
|
+
```
|
146
|
+
|
147
|
+
Specify the name of the format to use different formatting rules:
|
148
|
+
|
149
|
+
```ruby
|
150
|
+
Monies.format(Monies('1234.56', 'USD'), :eu) # "1.234,56"
|
151
|
+
```
|
152
|
+
|
153
|
+
The default format is `:en` and can be changed by updating `Monies.formats`,
|
154
|
+
for example to change the default format to the built-in `:eu` format:
|
155
|
+
|
156
|
+
```ruby
|
157
|
+
Monies.formats[:default] = Monies.formats[:eu]
|
158
|
+
```
|
159
|
+
|
160
|
+
To create a custom format first create a `Monies::Format` subclass,
|
161
|
+
and then add the format to `Monies.formats`. For example:
|
162
|
+
|
163
|
+
```ruby
|
164
|
+
class CustomFormat < Monies::Format::EN
|
165
|
+
# ...
|
166
|
+
end
|
167
|
+
|
168
|
+
Monies.formats[:custom] = CustomFormat.new
|
169
|
+
```
|
170
|
+
|
171
|
+
|
172
|
+
## BigDecimal integration
|
173
|
+
|
174
|
+
Monies integrates with the [bigdecimal gem](https://rubygems.org/gems/bigdecimal)
|
175
|
+
for multiplication and currency conversion. For example:
|
176
|
+
|
177
|
+
```ruby
|
178
|
+
require 'bigdecimal/util'
|
179
|
+
|
180
|
+
Monies('1.23', 'USD') * BigDecimal('0.1')
|
181
|
+
```
|
182
|
+
|
183
|
+
Use the `Monies` method to convert from a `BigDecimal` value:
|
184
|
+
|
185
|
+
```ruby
|
186
|
+
Monies(BigDecimal(10), 'USD')
|
187
|
+
```
|
188
|
+
|
189
|
+
Use the #to_d method to convert to a `BigDecimal` value:
|
190
|
+
|
191
|
+
```ruby
|
192
|
+
monies.to_d
|
193
|
+
```
|
194
|
+
|
195
|
+
Specify a currency argument to use `BigDecimal` values for currency conversion:
|
196
|
+
|
197
|
+
```ruby
|
198
|
+
monies = Monies('1.11', 'BTC')
|
199
|
+
monies.convert(BigDecimal(100_000), 'USD').round(2)
|
200
|
+
```
|
201
|
+
|
202
|
+
|
203
|
+
## Percentage integration
|
204
|
+
|
205
|
+
Monies integrates with the [percentage gem](https://rubygems.org/gems/percentage)
|
206
|
+
for percentage calculations. For example:
|
207
|
+
|
208
|
+
```ruby
|
209
|
+
require 'percentage'
|
210
|
+
|
211
|
+
capital_gains = Monies(10_000, 'GBP')
|
212
|
+
|
213
|
+
tax_rate = Percentage.new(20)
|
214
|
+
|
215
|
+
tax_liability = capital_gains * tax_rate
|
216
|
+
|
217
|
+
puts tax_liability
|
218
|
+
```
|
219
|
+
|
220
|
+
|
221
|
+
## Sequel integration
|
222
|
+
|
223
|
+
Monies integrates with the [sequel gem](https://rubygems.org/gems/sequel)
|
224
|
+
to support database serialization. For example:
|
225
|
+
|
226
|
+
```ruby
|
227
|
+
class Product < Sequel::Model
|
228
|
+
plugin Monies::Serialization::Sequel
|
229
|
+
|
230
|
+
serialize_monies :price
|
231
|
+
end
|
232
|
+
```
|
233
|
+
|
234
|
+
This will serialize the value and the currency as a single string.
|
235
|
+
|
236
|
+
You can also specify the currency at the model/application level using the
|
237
|
+
currency keyword argument:
|
238
|
+
|
239
|
+
```ruby
|
240
|
+
serialize_monies :price, currency: Monies.currency
|
241
|
+
```
|
242
|
+
|
243
|
+
This will serialize just the value as a single string.
|
244
|
+
|
245
|
+
You can also use two columns, one for the value and an additional string column
|
246
|
+
to store the currency:
|
247
|
+
|
248
|
+
```ruby
|
249
|
+
serialize_monies :price, currency: :currency
|
250
|
+
```
|
251
|
+
|
252
|
+
## ActiveRecord integration
|
253
|
+
|
254
|
+
Monies integrates with the [activerecord gem](https://rubygems.org/gems/activerecord)
|
255
|
+
to support database serialization. For example:
|
256
|
+
|
257
|
+
```ruby
|
258
|
+
class Product < ApplicationRecord
|
259
|
+
include Monies::Serialization::ActiveRecord
|
260
|
+
|
261
|
+
serialize_monies :price
|
262
|
+
end
|
263
|
+
```
|
264
|
+
|
265
|
+
Usage of `serialize_monies` is identical to the [sequel integration](#sequel-integration).
|
266
|
+
|
267
|
+
|
268
|
+
## License
|
269
|
+
|
270
|
+
Monies is released under the LGPL-3.0 license.
|
@@ -0,0 +1,63 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Monies::Digits
|
4
|
+
def self.dump(instance, scale: nil, zero: '0', separator: '.', thousands_separator: nil)
|
5
|
+
return zero if instance.zero?
|
6
|
+
|
7
|
+
string = instance.value.abs.to_s
|
8
|
+
|
9
|
+
integral_length = string.length - instance.scale
|
10
|
+
|
11
|
+
unless thousands_separator.nil?
|
12
|
+
index = integral_length
|
13
|
+
while index > 3
|
14
|
+
index -= 3
|
15
|
+
string.insert(index, thousands_separator)
|
16
|
+
integral_length += thousands_separator.length
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
unless instance.scale.zero? && scale.nil? || scale == 0
|
21
|
+
if integral_length > 0
|
22
|
+
string.insert(integral_length, separator)
|
23
|
+
else
|
24
|
+
string = string.rjust(instance.scale, '0') if integral_length < 0
|
25
|
+
string.insert(0, separator)
|
26
|
+
string.insert(0, '0')
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
unless scale.nil?
|
31
|
+
if scale > instance.scale
|
32
|
+
string = string.ljust(string.length + scale - instance.scale, '0')
|
33
|
+
elsif scale < instance.scale
|
34
|
+
string.slice!(scale - instance.scale .. -1)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
string.insert(0, '-') if instance.negative?
|
39
|
+
string
|
40
|
+
end
|
41
|
+
|
42
|
+
def self.load(string, currency)
|
43
|
+
integral_digits, fractional_digits = string.split('.')
|
44
|
+
|
45
|
+
value = integral_digits.to_i
|
46
|
+
|
47
|
+
if fractional_digits.nil?
|
48
|
+
scale = 0
|
49
|
+
else
|
50
|
+
scale = fractional_digits.length
|
51
|
+
|
52
|
+
value *= Monies::BASE ** scale
|
53
|
+
|
54
|
+
if string.start_with?('-')
|
55
|
+
value -= fractional_digits.to_i
|
56
|
+
else
|
57
|
+
value += fractional_digits.to_i
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
Monies.new(value, scale, currency)
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Monies::Format
|
4
|
+
def call(instance, symbol: false, code: false)
|
5
|
+
if symbol && code
|
6
|
+
raise ArgumentError, "can't format with both symbol and code keyword arguments"
|
7
|
+
end
|
8
|
+
|
9
|
+
digits = Monies::Digits.dump(instance, scale: scale, zero: zero, separator: separator, thousands_separator: thousands_separator)
|
10
|
+
|
11
|
+
if symbol
|
12
|
+
Monies.symbols.fetch_key(instance.currency) + digits
|
13
|
+
elsif code
|
14
|
+
"#{digits} #{instance.currency}"
|
15
|
+
else
|
16
|
+
digits
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
class Monies::Format::EN < Monies::Format
|
22
|
+
def scale
|
23
|
+
2
|
24
|
+
end
|
25
|
+
|
26
|
+
def zero
|
27
|
+
'0.00'
|
28
|
+
end
|
29
|
+
|
30
|
+
def separator
|
31
|
+
'.'
|
32
|
+
end
|
33
|
+
|
34
|
+
def thousands_separator
|
35
|
+
','
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
class Monies::Format::EU < Monies::Format
|
40
|
+
def scale
|
41
|
+
2
|
42
|
+
end
|
43
|
+
|
44
|
+
def zero
|
45
|
+
'0,00'
|
46
|
+
end
|
47
|
+
|
48
|
+
def separator
|
49
|
+
','
|
50
|
+
end
|
51
|
+
|
52
|
+
def thousands_separator
|
53
|
+
'.'
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,109 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require 'strscan'
|
3
|
+
|
4
|
+
class Monies::Parser < StringScanner
|
5
|
+
def parse
|
6
|
+
if comma_decimal_separator?
|
7
|
+
@decimal_separator = COMMA
|
8
|
+
|
9
|
+
@thousands_separator = POINT_THINSP
|
10
|
+
else
|
11
|
+
@decimal_separator = POINT
|
12
|
+
|
13
|
+
@thousands_separator = COMMA
|
14
|
+
end
|
15
|
+
|
16
|
+
parse_minus_sign
|
17
|
+
parse_currency_symbol
|
18
|
+
parse_integral_digits
|
19
|
+
parse_fractional_digits
|
20
|
+
parse_space
|
21
|
+
parse_currency_code
|
22
|
+
|
23
|
+
if @integral_digits.nil?
|
24
|
+
raise ArgumentError, "can't parse #{string.inspect}"
|
25
|
+
end
|
26
|
+
|
27
|
+
if @fractional_digits.nil?
|
28
|
+
value = @integral_digits.to_i
|
29
|
+
|
30
|
+
scale = 0
|
31
|
+
else
|
32
|
+
value = (@integral_digits + @fractional_digits).to_i
|
33
|
+
|
34
|
+
scale = @fractional_digits.length
|
35
|
+
end
|
36
|
+
|
37
|
+
currency = if @currency_code
|
38
|
+
@currency_code
|
39
|
+
elsif @currency_symbol
|
40
|
+
Monies.symbols.fetch(@currency_symbol)
|
41
|
+
elsif Monies.currency
|
42
|
+
Monies.currency
|
43
|
+
else
|
44
|
+
raise ArgumentError, "can't parse #{string.inspect} without currency"
|
45
|
+
end
|
46
|
+
|
47
|
+
if @minus_sign
|
48
|
+
-Monies.new(value, scale, currency)
|
49
|
+
else
|
50
|
+
Monies.new(value, scale, currency)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
DIGITS = /\d+/
|
57
|
+
|
58
|
+
POINT = /\./
|
59
|
+
|
60
|
+
COMMA = /,/
|
61
|
+
|
62
|
+
COMMA_TWO_DIGITS = /,\d{2}\b/
|
63
|
+
|
64
|
+
POINT_THINSP = /[\.\u{2009}]/
|
65
|
+
|
66
|
+
MINUS = /-/
|
67
|
+
|
68
|
+
SPACE = /\s/
|
69
|
+
|
70
|
+
def comma_decimal_separator?
|
71
|
+
return true if string =~ COMMA_TWO_DIGITS
|
72
|
+
|
73
|
+
point_index = string =~ POINT
|
74
|
+
|
75
|
+
comma_index = string =~ COMMA
|
76
|
+
|
77
|
+
comma_index && point_index && comma_index > point_index
|
78
|
+
end
|
79
|
+
|
80
|
+
def parse_minus_sign
|
81
|
+
@minus_sign = scan(MINUS)
|
82
|
+
end
|
83
|
+
|
84
|
+
def parse_currency_symbol
|
85
|
+
@currency_symbol = scan(Monies.symbols.keys)
|
86
|
+
end
|
87
|
+
|
88
|
+
def parse_integral_digits
|
89
|
+
parse_minus_sign if @minus_sign.nil?
|
90
|
+
|
91
|
+
@integral_digits = scan(DIGITS)
|
92
|
+
|
93
|
+
while scan(@thousands_separator)
|
94
|
+
@integral_digits += scan(DIGITS)
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
def parse_fractional_digits
|
99
|
+
@fractional_digits = scan(@decimal_separator) && scan(DIGITS)
|
100
|
+
end
|
101
|
+
|
102
|
+
def parse_space
|
103
|
+
scan(SPACE)
|
104
|
+
end
|
105
|
+
|
106
|
+
def parse_currency_code
|
107
|
+
@currency_code = scan(Monies.symbols.values)
|
108
|
+
end
|
109
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Monies::Serialization::ActiveRecord
|
4
|
+
module ClassMethods
|
5
|
+
def serialize_monies(column, currency: nil)
|
6
|
+
if currency.is_a?(Symbol) && !column_names.include?(currency.to_s)
|
7
|
+
raise RuntimeError, "missing currency column #{currency.inspect}"
|
8
|
+
end
|
9
|
+
|
10
|
+
column_type = columns.find { _1.name == column.to_s }.sql_type_metadata.type
|
11
|
+
|
12
|
+
serialize_monies_attribute(column, column_type, currency)
|
13
|
+
end
|
14
|
+
|
15
|
+
def serialize_monies_string(column)
|
16
|
+
serialize(column, coder: Monies)
|
17
|
+
end
|
18
|
+
|
19
|
+
def predicate_builder
|
20
|
+
@predicate_builder ||= super().tap { _1.register_handler(Monies, PredicateBuilderHandler.new(_1, self)) }
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
class PredicateBuilderHandler
|
25
|
+
def initialize(predicate_builder, model)
|
26
|
+
@predicate_builder, @model = predicate_builder, model
|
27
|
+
end
|
28
|
+
|
29
|
+
def call(attribute, value)
|
30
|
+
currency = @model.send(:"#{attribute.name}_currency")
|
31
|
+
|
32
|
+
if currency.nil?
|
33
|
+
@predicate_builder.build(attribute, Monies.dump(value))
|
34
|
+
elsif currency.is_a?(String)
|
35
|
+
unless value.nil? || value.currency == currency
|
36
|
+
raise Monies::CurrencyError, "can't serialize #{value.currency} to #{currency}"
|
37
|
+
end
|
38
|
+
|
39
|
+
@predicate_builder.build(attribute, @model.send(:"serialize_#{attribute.name}", value))
|
40
|
+
elsif currency.is_a?(Symbol)
|
41
|
+
Arel::Nodes::And.new([
|
42
|
+
@predicate_builder.build(attribute, @model.send(:"serialize_#{attribute.name}", value)),
|
43
|
+
@predicate_builder[currency, value.currency]
|
44
|
+
])
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def self.included(model)
|
50
|
+
model.extend Monies::Serialization::ClassMethods
|
51
|
+
model.extend ClassMethods
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Monies::Serialization::Sequel
|
4
|
+
module ClassMethods
|
5
|
+
def serialize_monies(column, currency: nil)
|
6
|
+
if currency.is_a?(Symbol) && !columns.include?(currency)
|
7
|
+
raise RuntimeError, "missing currency column #{currency.inspect}"
|
8
|
+
end
|
9
|
+
|
10
|
+
column_type = db_schema.fetch(column).fetch(:type)
|
11
|
+
|
12
|
+
serialize_monies_attribute(column, column_type, currency)
|
13
|
+
end
|
14
|
+
|
15
|
+
def serialize_monies_string(column)
|
16
|
+
require 'sequel/plugins/serialization'
|
17
|
+
|
18
|
+
plugin(:serialization) unless respond_to?(:serialization_map)
|
19
|
+
|
20
|
+
serializer, deserializer = Monies.method(:dump), Monies.method(:load)
|
21
|
+
|
22
|
+
define_serialized_attribute_accessor(serializer, deserializer, column)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
module BooleanExpressionPatch
|
27
|
+
def to_s_append(dataset, sql)
|
28
|
+
column, value = args[0], args[1]
|
29
|
+
|
30
|
+
return super(dataset, sql) unless value.is_a?(Monies)
|
31
|
+
|
32
|
+
column = column.value.to_sym if column.is_a?(::Sequel::SQL::Identifier)
|
33
|
+
|
34
|
+
currency = dataset.model.send(:"#{column}_currency")
|
35
|
+
|
36
|
+
sql << '('
|
37
|
+
dataset.literal_append(sql, column)
|
38
|
+
sql << ' ' << op.to_s << ' '
|
39
|
+
|
40
|
+
if currency.nil?
|
41
|
+
dataset.literal_append(sql, Monies.dump(value))
|
42
|
+
elsif currency.is_a?(String)
|
43
|
+
unless value.nil? || value.currency == currency
|
44
|
+
raise Monies::CurrencyError, "can't serialize #{value.currency} to #{currency}"
|
45
|
+
end
|
46
|
+
|
47
|
+
dataset.literal_append(sql, dataset.model.send(:"serialize_#{column}", value))
|
48
|
+
elsif currency.is_a?(Symbol)
|
49
|
+
dataset.literal_append(sql, dataset.model.send(:"serialize_#{column}", value))
|
50
|
+
sql << ' AND '
|
51
|
+
dataset.literal_append(sql, currency)
|
52
|
+
sql << ' = '
|
53
|
+
dataset.literal_append(sql, value.currency)
|
54
|
+
end
|
55
|
+
|
56
|
+
sql << ')'
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
::Sequel::SQL::BooleanExpression.class_eval do
|
61
|
+
include BooleanExpressionPatch
|
62
|
+
end
|
63
|
+
|
64
|
+
def self.apply(model)
|
65
|
+
model.extend Monies::Serialization::ClassMethods
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
module Monies::Serialization
|
2
|
+
autoload :ActiveRecord, 'monies/serialization/active_record'
|
3
|
+
autoload :Sequel, 'monies/serialization/sequel'
|
4
|
+
|
5
|
+
module ClassMethods
|
6
|
+
def serialize_monies_attribute(column, column_type, currency)
|
7
|
+
singleton_class.define_method(:"#{column}_currency") { currency }
|
8
|
+
|
9
|
+
if currency.nil?
|
10
|
+
unless column_type == :string
|
11
|
+
raise ArgumentError, "can't serialize monies to #{column_type} column without currency"
|
12
|
+
end
|
13
|
+
|
14
|
+
return serialize_monies_string(column)
|
15
|
+
elsif currency.is_a?(Symbol)
|
16
|
+
if column_type == :string
|
17
|
+
define_method(:"deserialize_#{column}") { |value| Monies::Digits.load(value, self[currency]) }
|
18
|
+
else
|
19
|
+
define_method(:"deserialize_#{column}") { |value| Monies(value, self[currency]) }
|
20
|
+
end
|
21
|
+
|
22
|
+
define_method(:"#{column}=") do |value|
|
23
|
+
result = super(value.nil? ? nil : self.class.send(:"serialize_#{column}", value))
|
24
|
+
send(:"#{currency}=", value&.currency)
|
25
|
+
result
|
26
|
+
end
|
27
|
+
elsif currency.is_a?(String)
|
28
|
+
if column_type == :string
|
29
|
+
define_method(:"deserialize_#{column}") { |value| Monies::Digits.load(value, currency) }
|
30
|
+
else
|
31
|
+
define_method(:"deserialize_#{column}") { |value| Monies(value, currency) }
|
32
|
+
end
|
33
|
+
|
34
|
+
define_method(:"#{column}=") do |value|
|
35
|
+
unless value.nil? || value.currency == currency
|
36
|
+
raise Monies::CurrencyError, "can't serialize #{value.currency} to #{currency}"
|
37
|
+
end
|
38
|
+
|
39
|
+
super(value.nil? ? nil : self.class.send(:"serialize_#{column}", value))
|
40
|
+
end
|
41
|
+
else
|
42
|
+
raise ArgumentError, "can't serialize monies with #{currency.class} currency"
|
43
|
+
end
|
44
|
+
|
45
|
+
if column_type == :string
|
46
|
+
singleton_class.define_method(:"serialize_#{column}") { |value| Monies::Digits.dump(value) }
|
47
|
+
else
|
48
|
+
singleton_class.define_method(:"serialize_#{column}") { |value| value.to_d }
|
49
|
+
end
|
50
|
+
|
51
|
+
define_method(column) do
|
52
|
+
value = super()
|
53
|
+
|
54
|
+
send(:"deserialize_#{column}", value) unless value.nil?
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Monies::Symbols
|
4
|
+
def initialize
|
5
|
+
@hash, @inverse_hash = Hash.new, Hash.new
|
6
|
+
end
|
7
|
+
|
8
|
+
def keys
|
9
|
+
@keys ||= Regexp.new(@hash.keys.map { Regexp.escape(_1) }.join('|'))
|
10
|
+
end
|
11
|
+
|
12
|
+
def values
|
13
|
+
@values ||= Regexp.new(@hash.values.map { Regexp.escape(_1) }.join('|'))
|
14
|
+
end
|
15
|
+
|
16
|
+
def fetch(key)
|
17
|
+
@hash.fetch(key)
|
18
|
+
end
|
19
|
+
alias_method :[], :fetch
|
20
|
+
|
21
|
+
def fetch_key(value)
|
22
|
+
@inverse_hash.fetch(value)
|
23
|
+
end
|
24
|
+
|
25
|
+
def store(key, value)
|
26
|
+
@hash[key] = value
|
27
|
+
|
28
|
+
@inverse_hash[value] = key
|
29
|
+
|
30
|
+
@keys, @values = nil
|
31
|
+
|
32
|
+
value
|
33
|
+
end
|
34
|
+
alias_method :[]=, :store
|
35
|
+
|
36
|
+
def update(hash)
|
37
|
+
@hash.update(hash)
|
38
|
+
|
39
|
+
@inverse_hash = @hash.invert
|
40
|
+
|
41
|
+
@keys, @values = nil
|
42
|
+
|
43
|
+
self
|
44
|
+
end
|
45
|
+
end
|
data/lib/monies.rb
ADDED
@@ -0,0 +1,493 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Monies
|
4
|
+
BASE = 10
|
5
|
+
|
6
|
+
CurrencyError = Class.new(ArgumentError)
|
7
|
+
|
8
|
+
autoload :Digits, 'monies/digits'
|
9
|
+
autoload :Format, 'monies/format'
|
10
|
+
autoload :Parser, 'monies/parser'
|
11
|
+
autoload :Serialization, 'monies/serialization'
|
12
|
+
autoload :Symbols, 'monies/symbols'
|
13
|
+
|
14
|
+
class << self
|
15
|
+
attr_accessor :currency
|
16
|
+
attr_accessor :formats
|
17
|
+
attr_accessor :symbols
|
18
|
+
end
|
19
|
+
|
20
|
+
self.currency = nil
|
21
|
+
|
22
|
+
self.formats = {
|
23
|
+
default: Monies::Format::EN.new,
|
24
|
+
en: Monies::Format::EN.new,
|
25
|
+
eu: Monies::Format::EU.new,
|
26
|
+
}
|
27
|
+
|
28
|
+
self.symbols = Symbols.new.update({
|
29
|
+
'$' => 'USD',
|
30
|
+
'€' => 'EUR',
|
31
|
+
'¥' => 'JPY',
|
32
|
+
'£' => 'GBP',
|
33
|
+
'A$' => 'AUD',
|
34
|
+
'C$' => 'CAD',
|
35
|
+
'CHF' => 'CHF',
|
36
|
+
'元' => 'CNY',
|
37
|
+
'HK$' => 'HKD',
|
38
|
+
'NZ$' => 'NZD',
|
39
|
+
'S$' => 'SGD',
|
40
|
+
'₹' => 'INR',
|
41
|
+
'MX$' => 'MXN',
|
42
|
+
})
|
43
|
+
|
44
|
+
def self.dump(value)
|
45
|
+
return value unless value.is_a?(self)
|
46
|
+
|
47
|
+
"#{Monies::Digits.dump(value)} #{value.currency}"
|
48
|
+
end
|
49
|
+
|
50
|
+
def self.format(value, name = :default, symbol: false, code: false)
|
51
|
+
unless formats.key?(name)
|
52
|
+
raise ArgumentError, "#{name.inspect} is not a valid format"
|
53
|
+
end
|
54
|
+
|
55
|
+
return if value.nil?
|
56
|
+
|
57
|
+
formats[name].call(value, symbol: symbol, code: code)
|
58
|
+
end
|
59
|
+
|
60
|
+
def self.load(string)
|
61
|
+
return if string.nil?
|
62
|
+
|
63
|
+
digits, currency = string.split
|
64
|
+
|
65
|
+
Monies::Digits.load(digits, currency)
|
66
|
+
end
|
67
|
+
|
68
|
+
def self.parse(string)
|
69
|
+
Parser.new(string).parse
|
70
|
+
end
|
71
|
+
|
72
|
+
def self._load(string)
|
73
|
+
value, scale, currency = string.split
|
74
|
+
|
75
|
+
new(value.to_i, scale.to_i, currency)
|
76
|
+
end
|
77
|
+
|
78
|
+
def initialize(value, scale, currency = self.class.currency)
|
79
|
+
unless value.is_a?(Integer)
|
80
|
+
raise ArgumentError, "#{value.inspect} is not a valid value argument"
|
81
|
+
end
|
82
|
+
|
83
|
+
unless scale.is_a?(Integer) && scale >= 0
|
84
|
+
raise ArgumentError, "#{scale.inspect} is not a valid scale argument"
|
85
|
+
end
|
86
|
+
|
87
|
+
unless currency.is_a?(String)
|
88
|
+
raise ArgumentError, "#{currency.inspect} is not a valid currency argument"
|
89
|
+
end
|
90
|
+
|
91
|
+
@value, @scale, @currency = value, scale, currency
|
92
|
+
|
93
|
+
freeze
|
94
|
+
end
|
95
|
+
|
96
|
+
def *(other)
|
97
|
+
if other.is_a?(Integer)
|
98
|
+
return reduce(@value * other, @scale)
|
99
|
+
end
|
100
|
+
|
101
|
+
if other.is_a?(Rational)
|
102
|
+
return self * other.numerator / other.denominator
|
103
|
+
end
|
104
|
+
|
105
|
+
if other.respond_to?(:to_d) && !other.is_a?(self.class)
|
106
|
+
other = other.to_d
|
107
|
+
|
108
|
+
sign, significant_digits, base, exponent = other.split
|
109
|
+
|
110
|
+
value = significant_digits.to_i * sign
|
111
|
+
|
112
|
+
length = significant_digits.length
|
113
|
+
|
114
|
+
if exponent.positive? && length < exponent
|
115
|
+
value *= base ** (exponent - length)
|
116
|
+
end
|
117
|
+
|
118
|
+
scale = other.scale
|
119
|
+
|
120
|
+
return reduce(@value * value, @scale + scale)
|
121
|
+
end
|
122
|
+
|
123
|
+
raise TypeError, "#{self.class} can't be multiplied by #{other.class}"
|
124
|
+
end
|
125
|
+
|
126
|
+
def +(other)
|
127
|
+
if other.respond_to?(:zero?) && other.zero?
|
128
|
+
return self
|
129
|
+
end
|
130
|
+
|
131
|
+
unless other.is_a?(self.class)
|
132
|
+
raise TypeError, "can't add #{other.class} to #{self.class}"
|
133
|
+
end
|
134
|
+
|
135
|
+
unless other.currency == @currency
|
136
|
+
raise CurrencyError, "can't add #{other.currency} to #{@currency}"
|
137
|
+
end
|
138
|
+
|
139
|
+
add(other)
|
140
|
+
end
|
141
|
+
|
142
|
+
def -(other)
|
143
|
+
if other.respond_to?(:zero?) && other.zero?
|
144
|
+
return self
|
145
|
+
end
|
146
|
+
|
147
|
+
unless other.is_a?(self.class)
|
148
|
+
raise TypeError, "can't subtract #{other.class} from #{self.class}"
|
149
|
+
end
|
150
|
+
|
151
|
+
unless other.currency == @currency
|
152
|
+
raise CurrencyError, "can't subtract #{other.currency} from #{@currency}"
|
153
|
+
end
|
154
|
+
|
155
|
+
add(-other)
|
156
|
+
end
|
157
|
+
|
158
|
+
def -@
|
159
|
+
self.class.new(-@value, @scale, @currency)
|
160
|
+
end
|
161
|
+
|
162
|
+
def /(other)
|
163
|
+
div(other)
|
164
|
+
end
|
165
|
+
|
166
|
+
def <=>(other)
|
167
|
+
if other.is_a?(self.class)
|
168
|
+
unless other.currency == @currency
|
169
|
+
raise CurrencyError, "can't compare #{other.currency} with #{@currency}"
|
170
|
+
end
|
171
|
+
|
172
|
+
value, other_value = @value, other.value
|
173
|
+
|
174
|
+
if other.scale > @scale
|
175
|
+
value *= BASE ** (other.scale - @scale)
|
176
|
+
elsif other.scale < @scale
|
177
|
+
other_value *= BASE ** (@scale - other.scale)
|
178
|
+
end
|
179
|
+
|
180
|
+
value <=> other_value
|
181
|
+
elsif other.respond_to?(:zero?) && other.zero?
|
182
|
+
@value <=> other
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
include Comparable
|
187
|
+
|
188
|
+
def _dump(_level)
|
189
|
+
"#{@value} #{@scale} #{@currency}"
|
190
|
+
end
|
191
|
+
|
192
|
+
def abs
|
193
|
+
return self unless negative?
|
194
|
+
|
195
|
+
self.class.new(@value.abs, @scale, @currency)
|
196
|
+
end
|
197
|
+
|
198
|
+
def ceil(digits = 0)
|
199
|
+
round(digits, :ceil)
|
200
|
+
end
|
201
|
+
|
202
|
+
def coerce(other)
|
203
|
+
unless other.respond_to?(:zero?) && other.zero?
|
204
|
+
raise TypeError, "#{self.class} can't be coerced into #{other.class}"
|
205
|
+
end
|
206
|
+
|
207
|
+
return self, other
|
208
|
+
end
|
209
|
+
|
210
|
+
def convert(other, currency = nil)
|
211
|
+
if other.is_a?(self.class)
|
212
|
+
unless currency.nil?
|
213
|
+
raise ArgumentError, "#{self.class} can't be converted with #{other.class} and currency argument"
|
214
|
+
end
|
215
|
+
|
216
|
+
return self if @currency == other.currency
|
217
|
+
|
218
|
+
return other * to_r
|
219
|
+
end
|
220
|
+
|
221
|
+
if other.is_a?(Integer) || other.is_a?(Rational)
|
222
|
+
return Monies(to_r * other, currency || Monies.currency)
|
223
|
+
end
|
224
|
+
|
225
|
+
if defined?(BigDecimal) && other.is_a?(BigDecimal)
|
226
|
+
return Monies(to_d * other, currency || Monies.currency)
|
227
|
+
end
|
228
|
+
|
229
|
+
raise TypeError, "#{self.class} can't be converted with #{other.class}"
|
230
|
+
end
|
231
|
+
|
232
|
+
def currency
|
233
|
+
@currency
|
234
|
+
end
|
235
|
+
|
236
|
+
def div(other, digits = 16)
|
237
|
+
unless digits.is_a?(Integer) && digits >= 1
|
238
|
+
raise ArgumentError, 'digits must be greater than or equal to 1'
|
239
|
+
end
|
240
|
+
|
241
|
+
if other.respond_to?(:zero?) && other.zero?
|
242
|
+
raise ZeroDivisionError, 'divided by 0'
|
243
|
+
end
|
244
|
+
|
245
|
+
if other.is_a?(self.class)
|
246
|
+
unless other.currency == @currency
|
247
|
+
raise CurrencyError, "can't divide #{@currency} by #{other.currency}"
|
248
|
+
end
|
249
|
+
|
250
|
+
scale = @scale - other.scale
|
251
|
+
|
252
|
+
if scale.negative?
|
253
|
+
return divide(@value * BASE ** scale.abs, 0, other.value, digits)
|
254
|
+
else
|
255
|
+
return divide(@value, scale, other.value, digits)
|
256
|
+
end
|
257
|
+
end
|
258
|
+
|
259
|
+
if other.is_a?(Integer)
|
260
|
+
return divide(@value, @scale, other, digits)
|
261
|
+
end
|
262
|
+
|
263
|
+
if other.is_a?(Rational)
|
264
|
+
return self * other.denominator / other.numerator
|
265
|
+
end
|
266
|
+
|
267
|
+
if defined?(BigDecimal) && other.is_a?(BigDecimal)
|
268
|
+
return self / Monies(other, @currency)
|
269
|
+
end
|
270
|
+
|
271
|
+
raise TypeError, "#{self.class} can't be divided by #{other.class}"
|
272
|
+
end
|
273
|
+
|
274
|
+
def floor(digits = 0)
|
275
|
+
round(digits, :floor)
|
276
|
+
end
|
277
|
+
|
278
|
+
def inspect
|
279
|
+
"#<#{self.class.name}: #{Monies::Digits.dump(self)} #{@currency}>"
|
280
|
+
end
|
281
|
+
|
282
|
+
def negative?
|
283
|
+
@value.negative?
|
284
|
+
end
|
285
|
+
|
286
|
+
def nonzero?
|
287
|
+
!@value.zero?
|
288
|
+
end
|
289
|
+
|
290
|
+
def positive?
|
291
|
+
@value.positive?
|
292
|
+
end
|
293
|
+
|
294
|
+
def precision
|
295
|
+
return 0 if @value.zero?
|
296
|
+
|
297
|
+
@value.to_s.length
|
298
|
+
end
|
299
|
+
|
300
|
+
def round(digits = 0, mode = :default, half: nil)
|
301
|
+
if half == :up
|
302
|
+
mode = :half_up
|
303
|
+
elsif half == :down
|
304
|
+
mode = :half_down
|
305
|
+
elsif half == :even
|
306
|
+
mode = :half_even
|
307
|
+
elsif !half.nil?
|
308
|
+
raise ArgumentError, "invalid rounding mode: #{half.inspect}"
|
309
|
+
end
|
310
|
+
|
311
|
+
case mode
|
312
|
+
when :banker, :ceil, :ceiling, :default, :down, :floor, :half_down, :half_even, :half_up, :truncate, :up
|
313
|
+
else
|
314
|
+
raise ArgumentError, "invalid rounding mode: #{mode.inspect}"
|
315
|
+
end
|
316
|
+
|
317
|
+
if digits >= @scale
|
318
|
+
return self
|
319
|
+
end
|
320
|
+
|
321
|
+
n = @scale - digits
|
322
|
+
|
323
|
+
array = @value.abs.digits
|
324
|
+
|
325
|
+
digit = array[n - 1]
|
326
|
+
|
327
|
+
case mode
|
328
|
+
when :ceiling, :ceil
|
329
|
+
round_digits!(array, n) if @value.positive?
|
330
|
+
when :floor
|
331
|
+
round_digits!(array, n) if @value.negative?
|
332
|
+
when :half_down
|
333
|
+
round_digits!(array, n) if (digit > 5 || (digit == 5 && n > 1))
|
334
|
+
when :half_even, :banker
|
335
|
+
round_digits!(array, n) if (digit > 5 || (digit == 5 && n > 1)) || digit == 5 && n == 1 && array[n].odd?
|
336
|
+
when :half_up, :default
|
337
|
+
round_digits!(array, n) if digit >= 5
|
338
|
+
when :up
|
339
|
+
round_digits!(array, n)
|
340
|
+
end
|
341
|
+
|
342
|
+
n.times { |i| array[i] = nil }
|
343
|
+
|
344
|
+
value = array.reverse.join.to_i
|
345
|
+
|
346
|
+
value = -value if @value.negative?
|
347
|
+
|
348
|
+
if digits.zero?
|
349
|
+
self.class.new(value, 0, currency)
|
350
|
+
else
|
351
|
+
reduce(value, digits)
|
352
|
+
end
|
353
|
+
end
|
354
|
+
|
355
|
+
def scale
|
356
|
+
@scale
|
357
|
+
end
|
358
|
+
|
359
|
+
def to_d
|
360
|
+
BigDecimal(Monies::Digits.dump(self))
|
361
|
+
end
|
362
|
+
|
363
|
+
def to_i
|
364
|
+
@value / BASE ** @scale
|
365
|
+
end
|
366
|
+
|
367
|
+
def to_r
|
368
|
+
Rational(@value, BASE ** @scale)
|
369
|
+
end
|
370
|
+
|
371
|
+
alias to_s inspect
|
372
|
+
|
373
|
+
def truncate(digits = 0)
|
374
|
+
return self if digits >= @scale
|
375
|
+
|
376
|
+
reduce(@value / BASE ** (@scale - digits), digits)
|
377
|
+
end
|
378
|
+
|
379
|
+
def value
|
380
|
+
@value
|
381
|
+
end
|
382
|
+
|
383
|
+
def zero?
|
384
|
+
@value.zero?
|
385
|
+
end
|
386
|
+
|
387
|
+
private
|
388
|
+
|
389
|
+
def divide(value, scale, divisor, max_scale)
|
390
|
+
quotient, carry = 0, 0
|
391
|
+
|
392
|
+
value.digits.reverse_each do |digit|
|
393
|
+
dividend = carry + digit
|
394
|
+
|
395
|
+
quotient = (quotient * BASE) + (dividend / divisor)
|
396
|
+
|
397
|
+
carry = (dividend % divisor) * BASE
|
398
|
+
end
|
399
|
+
|
400
|
+
iterations = max_scale - scale
|
401
|
+
|
402
|
+
until iterations.zero? || carry.zero?
|
403
|
+
dividend = carry
|
404
|
+
|
405
|
+
quotient = (quotient * BASE) + (dividend / divisor)
|
406
|
+
|
407
|
+
carry = (dividend % divisor) * BASE
|
408
|
+
|
409
|
+
scale += 1
|
410
|
+
|
411
|
+
iterations -= 1
|
412
|
+
end
|
413
|
+
|
414
|
+
self.class.new(quotient, scale, @currency)
|
415
|
+
end
|
416
|
+
|
417
|
+
def add(other)
|
418
|
+
value, other_value = @value, other.value
|
419
|
+
|
420
|
+
if other.scale > @scale
|
421
|
+
value *= BASE ** (other.scale - @scale)
|
422
|
+
|
423
|
+
scale = other.scale
|
424
|
+
else
|
425
|
+
scale = @scale
|
426
|
+
end
|
427
|
+
|
428
|
+
if other.scale < @scale
|
429
|
+
other_value *= BASE ** (@scale - other.scale)
|
430
|
+
end
|
431
|
+
|
432
|
+
value += other_value
|
433
|
+
|
434
|
+
reduce(value, scale)
|
435
|
+
end
|
436
|
+
|
437
|
+
def reduce(value, scale)
|
438
|
+
while scale > 0 && value.nonzero? && (value % BASE).zero?
|
439
|
+
value = value / BASE
|
440
|
+
|
441
|
+
scale -= 1
|
442
|
+
end
|
443
|
+
|
444
|
+
self.class.new(value, scale, @currency)
|
445
|
+
end
|
446
|
+
|
447
|
+
def round_digits!(array, index)
|
448
|
+
if index == array.size
|
449
|
+
array << 1
|
450
|
+
else
|
451
|
+
digit = array[index]
|
452
|
+
|
453
|
+
if digit == 9
|
454
|
+
array[index] = 0
|
455
|
+
|
456
|
+
round_digits!(array, index + 1)
|
457
|
+
else
|
458
|
+
array[index] += 1
|
459
|
+
end
|
460
|
+
end
|
461
|
+
end
|
462
|
+
end
|
463
|
+
|
464
|
+
def Monies(object, currency = Monies.currency)
|
465
|
+
case object
|
466
|
+
when Monies
|
467
|
+
object
|
468
|
+
when Integer
|
469
|
+
Monies.new(object, 0, currency)
|
470
|
+
when Rational
|
471
|
+
Monies.new(object.numerator, 0, currency) / object.denominator
|
472
|
+
when String
|
473
|
+
Monies::Digits.load(object, currency)
|
474
|
+
else
|
475
|
+
if defined?(BigDecimal) && object.is_a?(BigDecimal)
|
476
|
+
sign, significant_digits, base, exponent = object.split
|
477
|
+
|
478
|
+
value = significant_digits.to_i * sign
|
479
|
+
|
480
|
+
length = significant_digits.length
|
481
|
+
|
482
|
+
if exponent.positive? && length < exponent
|
483
|
+
value *= base ** (exponent - length)
|
484
|
+
end
|
485
|
+
|
486
|
+
scale = object.scale
|
487
|
+
|
488
|
+
return Monies.new(value, scale, currency)
|
489
|
+
end
|
490
|
+
|
491
|
+
raise TypeError, "can't convert #{object.inspect} into #{Monies}"
|
492
|
+
end
|
493
|
+
end
|
data/monies.gemspec
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
Gem::Specification.new do |s|
|
2
|
+
s.name = 'monies'
|
3
|
+
s.version = '1.0.0'
|
4
|
+
s.license = 'LGPL-3.0'
|
5
|
+
s.platform = Gem::Platform::RUBY
|
6
|
+
s.authors = ['Tim Craft']
|
7
|
+
s.email = ['email@timcraft.com']
|
8
|
+
s.homepage = 'https://github.com/readysteady/monies'
|
9
|
+
s.description = 'Ruby gem for representing monetary values'
|
10
|
+
s.summary = 'See description'
|
11
|
+
s.files = Dir.glob('lib/**/*.rb') + %w[LICENSE.txt README.md monies.gemspec]
|
12
|
+
s.required_ruby_version = '>= 3.1.0'
|
13
|
+
s.require_path = 'lib'
|
14
|
+
s.metadata = {
|
15
|
+
'homepage' => 'https://github.com/readysteady/monies',
|
16
|
+
'source_code_uri' => 'https://github.com/readysteady/monies',
|
17
|
+
'bug_tracker_uri' => 'https://github.com/readysteady/monies/issues',
|
18
|
+
}
|
19
|
+
end
|
metadata
ADDED
@@ -0,0 +1,57 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: monies
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Tim Craft
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2024-09-05 00:00:00.000000000 Z
|
12
|
+
dependencies: []
|
13
|
+
description: Ruby gem for representing monetary values
|
14
|
+
email:
|
15
|
+
- email@timcraft.com
|
16
|
+
executables: []
|
17
|
+
extensions: []
|
18
|
+
extra_rdoc_files: []
|
19
|
+
files:
|
20
|
+
- LICENSE.txt
|
21
|
+
- README.md
|
22
|
+
- lib/monies.rb
|
23
|
+
- lib/monies/digits.rb
|
24
|
+
- lib/monies/format.rb
|
25
|
+
- lib/monies/parser.rb
|
26
|
+
- lib/monies/serialization.rb
|
27
|
+
- lib/monies/serialization/active_record.rb
|
28
|
+
- lib/monies/serialization/sequel.rb
|
29
|
+
- lib/monies/symbols.rb
|
30
|
+
- monies.gemspec
|
31
|
+
homepage: https://github.com/readysteady/monies
|
32
|
+
licenses:
|
33
|
+
- LGPL-3.0
|
34
|
+
metadata:
|
35
|
+
homepage: https://github.com/readysteady/monies
|
36
|
+
source_code_uri: https://github.com/readysteady/monies
|
37
|
+
bug_tracker_uri: https://github.com/readysteady/monies/issues
|
38
|
+
post_install_message:
|
39
|
+
rdoc_options: []
|
40
|
+
require_paths:
|
41
|
+
- lib
|
42
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
43
|
+
requirements:
|
44
|
+
- - ">="
|
45
|
+
- !ruby/object:Gem::Version
|
46
|
+
version: 3.1.0
|
47
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
48
|
+
requirements:
|
49
|
+
- - ">="
|
50
|
+
- !ruby/object:Gem::Version
|
51
|
+
version: '0'
|
52
|
+
requirements: []
|
53
|
+
rubygems_version: 3.5.11
|
54
|
+
signing_key:
|
55
|
+
specification_version: 4
|
56
|
+
summary: See description
|
57
|
+
test_files: []
|