monies 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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
@@ -0,0 +1,4 @@
1
+ Copyright (c) 2024 TIMCRAFT
2
+
3
+ This is an Open Source project licensed under the terms of the LGPLv3 license.
4
+ Please see <http://www.gnu.org/licenses/lgpl-3.0.html> for license text.
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: []