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 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: []