monies 1.0.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 +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: []
|