shopify-money 0.10.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.
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Parse an amount from a string
4
+ class MoneyParser
5
+ class MoneyFormatError < ArgumentError; end
6
+
7
+ MARKS = %w[. , · ’ ˙ '] + [' ']
8
+
9
+ ESCAPED_MARKS = Regexp.escape(MARKS.join)
10
+ ESCAPED_NON_SPACE_MARKS = Regexp.escape((MARKS - [' ']).join)
11
+ ESCAPED_NON_DOT_MARKS = Regexp.escape((MARKS - ['.']).join)
12
+ ESCAPED_NON_COMMA_MARKS = Regexp.escape((MARKS - [',']).join)
13
+
14
+ NUMERIC_REGEX = /(
15
+ [\+\-]?
16
+ [\d#{ESCAPED_NON_SPACE_MARKS}][\d#{ESCAPED_MARKS}]*
17
+ )/ix
18
+
19
+ # 1,234,567.89
20
+ DOT_DECIMAL_REGEX = /\A
21
+ [\+\-]?
22
+ (?:
23
+ (?:\d+)
24
+ (?:[#{ESCAPED_NON_DOT_MARKS}]\d{3})+
25
+ (?:\.\d{2,})?
26
+ )
27
+ \z/ix
28
+
29
+ # 1.234.567,89
30
+ COMMA_DECIMAL_REGEX = /\A
31
+ [\+\-]?
32
+ (?:
33
+ (?:\d+)
34
+ (?:[#{ESCAPED_NON_COMMA_MARKS}]\d{3})+
35
+ (?:\,\d{2,})?
36
+ )
37
+ \z/ix
38
+
39
+ # 12,34,567.89
40
+ INDIAN_NUMERIC_REGEX = /\A
41
+ [\+\-]?
42
+ (?:
43
+ (?:\d+)
44
+ (?:\,\d{2})+
45
+ (?:\,\d{3})
46
+ (?:\.\d{2})?
47
+ )
48
+ \z/ix
49
+
50
+ # 1,1123,4567.89
51
+ CHINESE_NUMERIC_REGEX = /\A
52
+ [\+\-]?
53
+ (?:
54
+ (?:\d+)
55
+ (?:\,\d{4})+
56
+ (?:\.\d{2})?
57
+ )
58
+ \z/ix
59
+
60
+ def self.parse(input, currency = nil, **options)
61
+ new.parse(input, currency, **options)
62
+ end
63
+
64
+ def parse(input, currency = nil, strict: false)
65
+ currency = Money::Helpers.value_to_currency(currency)
66
+ amount = extract_money(input.to_s, currency, strict)
67
+ Money.new(amount, currency)
68
+ end
69
+
70
+ private
71
+
72
+ def extract_money(input, currency, strict)
73
+ if input.empty?
74
+ return '0'
75
+ end
76
+
77
+ number = input.scan(NUMERIC_REGEX).flatten.first
78
+ number = number.to_s.strip
79
+
80
+ if number.empty?
81
+ raise MoneyFormatError, "invalid money string: #{input}" if strict
82
+ Money.deprecate("invalid money strings will raise in the next major release \"#{input}\"")
83
+ return '0'
84
+ end
85
+
86
+ marks = number.scan(/[#{ESCAPED_MARKS}]/).flatten
87
+ if marks.empty?
88
+ return number
89
+ end
90
+
91
+ if marks.size == 1
92
+ return normalize_number(number, marks, currency)
93
+ end
94
+
95
+ # remove end of string mark
96
+ number.sub!(/[#{ESCAPED_MARKS}]\z/, '')
97
+
98
+ if amount = number[DOT_DECIMAL_REGEX] || number[INDIAN_NUMERIC_REGEX] || number[CHINESE_NUMERIC_REGEX]
99
+ return amount.tr(ESCAPED_NON_DOT_MARKS, '')
100
+ end
101
+
102
+ if amount = number[COMMA_DECIMAL_REGEX]
103
+ return amount.tr(ESCAPED_NON_COMMA_MARKS, '').sub(',', '.')
104
+ end
105
+
106
+ raise MoneyFormatError, "invalid money string: #{input}" if strict
107
+ Money.deprecate("invalid money strings will raise in the next major release \"#{input}\"")
108
+
109
+ normalize_number(number, marks, currency)
110
+ end
111
+
112
+ def normalize_number(number, marks, currency)
113
+ digits = number.rpartition(marks.last)
114
+ digits.first.tr!(ESCAPED_MARKS, '')
115
+
116
+ if last_digits_decimals?(digits, marks, currency)
117
+ "#{digits.first}.#{digits.last}"
118
+ else
119
+ "#{digits.first}#{digits.last}"
120
+ end
121
+ end
122
+
123
+ def last_digits_decimals?(digits, marks, currency)
124
+ # Thousands marks are always different from decimal marks
125
+ # Example: 1,234,456
126
+ *other_marks, last_mark = marks
127
+ other_marks.uniq!
128
+ if other_marks.size == 1
129
+ return other_marks.first != last_mark
130
+ end
131
+
132
+ # Thousands always have more than 2 digits
133
+ # Example: 1,23 must be 1 dollar and 23 cents
134
+ if digits.last.size < 3
135
+ return !digits.last.empty?
136
+ end
137
+
138
+ # 0 before the final mark indicates last digits are decimals
139
+ # Example: 0,23
140
+ if digits.first.to_i.zero?
141
+ return true
142
+ end
143
+
144
+ # legacy support for 1.000 USD
145
+ if digits.last.size == 3 && digits.first.size <= 3 && currency.minor_units < 3
146
+ return false
147
+ end
148
+
149
+ # The last mark matches the one used by the provided currency to delimiter decimals
150
+ currency.decimal_mark == last_mark
151
+ end
152
+ end
@@ -0,0 +1,35 @@
1
+ class Money
2
+ class NullCurrency
3
+
4
+ attr_reader :iso_code, :iso_numeric, :name, :smallest_denomination, :subunit_symbol,
5
+ :subunit_to_unit, :minor_units, :symbol, :disambiguate_symbol, :decimal_mark
6
+
7
+ def initialize
8
+ @symbol = '$'
9
+ @disambiguate_symbol = nil
10
+ @subunit_symbol = nil
11
+ @iso_code = 'XXX' # Valid ISO4217
12
+ @iso_numeric = '999'
13
+ @name = 'No Currency'
14
+ @smallest_denomination = 1
15
+ @subunit_to_unit = 100
16
+ @minor_units = 2
17
+ @decimal_mark = '.'
18
+ freeze
19
+ end
20
+
21
+ def compatible?(other)
22
+ other.is_a?(Currency) || other.is_a?(NullCurrency)
23
+ end
24
+
25
+ def eql?(other)
26
+ self.class == other.class && iso_code == other.iso_code
27
+ end
28
+
29
+ def to_s
30
+ ''
31
+ end
32
+
33
+ alias_method :==, :eql?
34
+ end
35
+ end
@@ -0,0 +1,3 @@
1
+ class Money
2
+ VERSION = "0.10.0"
3
+ end
@@ -0,0 +1,32 @@
1
+ module MoneyAccessor
2
+ def self.included(base)
3
+ base.extend(ClassMethods)
4
+ end
5
+
6
+ module ClassMethods
7
+ def money_accessor(*columns)
8
+ variable_get = self <= Struct ? :[] : :instance_variable_get
9
+ variable_set = self <= Struct ? :[]= : :instance_variable_set
10
+
11
+ Array(columns).flatten.each do |name|
12
+ variable_name = self <= Struct ? name : "@#{name}"
13
+
14
+ define_method(name) do
15
+ value = public_send(variable_get, variable_name)
16
+ value.blank? ? nil : Money.new(value)
17
+ end
18
+
19
+ define_method("#{name}=") do |value|
20
+ if value.blank? || !value.respond_to?(:to_money)
21
+ public_send(variable_set, variable_name, nil)
22
+ nil
23
+ else
24
+ money = value.to_money
25
+ public_send(variable_set, variable_name, money.value)
26
+ money
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,3 @@
1
+ require_relative 'money_column/active_record_hooks'
2
+ require_relative 'money_column/active_record_type'
3
+ require_relative 'money_column/railtie'
@@ -0,0 +1,95 @@
1
+ module MoneyColumn
2
+ module ActiveRecordHooks
3
+ def self.included(base)
4
+ base.extend(ClassMethods)
5
+ end
6
+
7
+ def reload(*)
8
+ clear_money_column_cache
9
+ super
10
+ end
11
+
12
+ def initialize_dup(*)
13
+ @money_column_cache = {}
14
+ super
15
+ end
16
+
17
+ private
18
+
19
+ def clear_money_column_cache
20
+ @money_column_cache.clear if persisted?
21
+ end
22
+
23
+ def init_internals
24
+ @money_column_cache = {}
25
+ super
26
+ end
27
+
28
+ module ClassMethods
29
+ def money_column(*columns, currency_column: nil, currency: nil, currency_read_only: false, coerce_null: false)
30
+ raise ArgumentError, 'cannot set both currency_column and a fixed currency' if currency && currency_column
31
+
32
+ if currency
33
+ currency_iso = Money::Currency.find!(currency).to_s
34
+ currency_read_only = true
35
+ elsif currency_column
36
+ clear_cache_on_currency_change(currency_column)
37
+ else
38
+ raise ArgumentError, 'must set one of :currency_column or :currency options'
39
+ end
40
+ currency_column = currency_column.to_s.freeze
41
+
42
+ columns.flatten.each do |column|
43
+ column_string = column.to_s.freeze
44
+ attribute(column_string, MoneyColumn::ActiveRecordType.new)
45
+ money_column_reader(column_string, currency_column, currency_iso, coerce_null)
46
+ money_column_writer(column_string, currency_column, currency_iso, currency_read_only)
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def clear_cache_on_currency_change(currency_column)
53
+ define_method "#{currency_column}=" do |value|
54
+ @money_column_cache.clear
55
+ super(value)
56
+ end
57
+ end
58
+
59
+ def money_column_reader(column, currency_column, currency_iso, coerce_null)
60
+ define_method column do
61
+ return @money_column_cache[column] if @money_column_cache[column]
62
+ value = read_attribute(column)
63
+ return if value.nil? && !coerce_null
64
+ iso = currency_iso || send(currency_column)
65
+ @money_column_cache[column] = Money.new(value, iso)
66
+ end
67
+ end
68
+
69
+ def money_column_writer(column, currency_column, currency_iso, currency_read_only)
70
+ define_method "#{column}=" do |money|
71
+ @money_column_cache[column] = nil
72
+
73
+ if money.blank?
74
+ write_attribute(column, nil)
75
+ return nil
76
+ end
77
+
78
+ currency_raw_source = currency_iso || (send(currency_column) rescue nil)
79
+ currency_source = Money::Helpers.value_to_currency(currency_raw_source)
80
+
81
+ if !money.is_a?(Money)
82
+ return write_attribute(column, Money.new(money, currency_source).value)
83
+ end
84
+
85
+ if currency_raw_source && !currency_source.compatible?(money.currency)
86
+ Money.deprecate("[money_column] currency mismatch between #{currency_source} and #{money.currency}.")
87
+ end
88
+
89
+ write_attribute(column, money.value)
90
+ write_attribute(currency_column, money.currency.to_s) unless currency_read_only || money.no_currency?
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,6 @@
1
+ class MoneyColumn::ActiveRecordType < ActiveRecord::Type::Decimal
2
+ def serialize(money)
3
+ return nil unless money
4
+ super(money.to_d)
5
+ end
6
+ end
@@ -0,0 +1,7 @@
1
+ module MoneyColumn
2
+ class Railtie < Rails::Railtie
3
+ ActiveSupport.on_load :active_record do
4
+ ActiveRecord::Base.send(:include, MoneyColumn::ActiveRecordHooks)
5
+ end
6
+ end
7
+ end
data/money.gemspec ADDED
@@ -0,0 +1,27 @@
1
+ # -*- encoding: utf-8 -*-
2
+ require_relative "lib/money/version"
3
+
4
+ Gem::Specification.new do |s|
5
+ s.name = "shopify-money"
6
+ s.version = Money::VERSION
7
+ s.platform = Gem::Platform::RUBY
8
+ s.authors = ["Shopify Inc"]
9
+ s.email = "gems@shopify.com"
10
+ s.description = "Manage money in Shopify with a class that wont lose pennies during division!"
11
+ s.homepage = "https://github.com/Shopify/money"
12
+ s.licenses = "MIT"
13
+ s.summary = "Shopify's money gem"
14
+
15
+ s.add_development_dependency("bundler", ">= 1.5")
16
+ s.add_development_dependency("simplecov", ">= 0")
17
+ s.add_development_dependency("rails", "~> 5.0")
18
+ s.add_development_dependency("rspec", "~> 3.2")
19
+ s.add_development_dependency("database_cleaner", "~> 1.6")
20
+ s.add_development_dependency("sqlite3", "~> 1.3")
21
+ s.add_development_dependency("bigdecimal", ">= 1.3.2")
22
+
23
+ s.files = `git ls-files`.split($/)
24
+ s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) }
25
+ s.test_files = s.files.grep(%r{^(test|spec|features)/})
26
+ s.require_paths = ["lib"]
27
+ end
@@ -0,0 +1,204 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe AccountingMoneyParser do
4
+ describe "parsing of amounts with period decimal separator" do
5
+ before(:each) do
6
+ @parser = AccountingMoneyParser.new
7
+ end
8
+
9
+ it "parses parenthesis as a negative amount eg (99.00)" do
10
+ expect(@parser.parse("(99.00)")).to eq(Money.new(-99.00))
11
+ end
12
+
13
+ it "parses parenthesis as a negative amount regardless of currency sign" do
14
+ expect(@parser.parse("($99.00)")).to eq(Money.new(-99.00))
15
+ end
16
+
17
+ it "parses an empty string to $0" do
18
+ expect(@parser.parse("")).to eq(Money.new)
19
+ end
20
+
21
+ it "parses an invalid string to $0" do
22
+ expect(Money).to receive(:deprecate).once
23
+ expect(@parser.parse("no money")).to eq(Money.new)
24
+ end
25
+
26
+ it "parses a single digit integer string" do
27
+ expect(@parser.parse("1")).to eq(Money.new(1.00))
28
+ end
29
+
30
+ it "parses a double digit integer string" do
31
+ expect(@parser.parse("10")).to eq(Money.new(10.00))
32
+ end
33
+
34
+ it "parses an integer string amount with a leading $" do
35
+ expect(@parser.parse("$1")).to eq(Money.new(1.00))
36
+ end
37
+
38
+ it "parses a float string amount" do
39
+ expect(@parser.parse("1.37")).to eq(Money.new(1.37))
40
+ end
41
+
42
+ it "parses a float string amount with a leading $" do
43
+ expect(@parser.parse("$1.37")).to eq(Money.new(1.37))
44
+ end
45
+
46
+ it "parses a float string with a single digit after the decimal" do
47
+ expect(@parser.parse("10.0")).to eq(Money.new(10.00))
48
+ end
49
+
50
+ it "parses a float string with two digits after the decimal" do
51
+ expect(@parser.parse("10.00")).to eq(Money.new(10.00))
52
+ end
53
+
54
+ it "parses the amount from an amount surrounded by whitespace and garbage" do
55
+ expect(@parser.parse("Rubbish $1.00 Rubbish")).to eq(Money.new(1.00))
56
+ end
57
+
58
+ it "parses the amount from an amount surrounded by garbage" do
59
+ expect(@parser.parse("Rubbish$1.00Rubbish")).to eq(Money.new(1.00))
60
+ end
61
+
62
+ it "parses a negative integer amount in the hundreds" do
63
+ expect(@parser.parse("-100")).to eq(Money.new(-100.00))
64
+ end
65
+
66
+ it "parses an integer amount in the hundreds" do
67
+ expect(@parser.parse("410")).to eq(Money.new(410.00))
68
+ end
69
+
70
+ it "parses a positive amount with a thousands separator" do
71
+ expect(@parser.parse("100,000.00")).to eq(Money.new(100_000.00))
72
+ end
73
+
74
+ it "parses a negative amount with a thousands separator" do
75
+ expect(@parser.parse("-100,000.00")).to eq(Money.new(-100_000.00))
76
+ end
77
+
78
+ it "parses negative $1.00" do
79
+ expect(@parser.parse("-1.00")).to eq(Money.new(-1.00))
80
+ end
81
+
82
+ it "parses a negative cents amount" do
83
+ expect(@parser.parse("-0.90")).to eq(Money.new(-0.90))
84
+ end
85
+
86
+ it "parses amount with 3 decimals and 0 dollar amount" do
87
+ expect(@parser.parse("0.123")).to eq(Money.new(0.12))
88
+ end
89
+
90
+ it "parses negative amount with 3 decimals and 0 dollar amount" do
91
+ expect(@parser.parse("-0.123")).to eq(Money.new(-0.12))
92
+ end
93
+
94
+ it "parses negative amount with multiple leading - signs" do
95
+ expect(@parser.parse("--0.123")).to eq(Money.new(-0.12))
96
+ end
97
+
98
+ it "parses negative amount with multiple - signs" do
99
+ expect(@parser.parse("--0.123--")).to eq(Money.new(-0.12))
100
+ end
101
+ end
102
+
103
+ describe "parsing of amounts with comma decimal separator" do
104
+ before(:each) do
105
+ @parser = AccountingMoneyParser.new
106
+ end
107
+
108
+ it "parses dollar amount $1,00 with leading $" do
109
+ expect(@parser.parse("$1,00")).to eq(Money.new(1.00))
110
+ end
111
+
112
+ it "parses dollar amount $1,37 with leading $, and non-zero cents" do
113
+ expect(@parser.parse("$1,37")).to eq(Money.new(1.37))
114
+ end
115
+
116
+ it "parses the amount from an amount surrounded by whitespace and garbage" do
117
+ expect(@parser.parse("Rubbish $1,00 Rubbish")).to eq(Money.new(1.00))
118
+ end
119
+
120
+ it "parses the amount from an amount surrounded by garbage" do
121
+ expect(@parser.parse("Rubbish$1,00Rubbish")).to eq(Money.new(1.00))
122
+ end
123
+
124
+ it "parses thousands amount" do
125
+ Money.with_currency(Money::NULL_CURRENCY) do
126
+ expect(@parser.parse("1.000")).to eq(Money.new(1000.00))
127
+ end
128
+ end
129
+
130
+ it "parses negative hundreds amount" do
131
+ expect(@parser.parse("-100,00")).to eq(Money.new(-100.00))
132
+ end
133
+
134
+ it "parses positive hundreds amount" do
135
+ expect(@parser.parse("410,00")).to eq(Money.new(410.00))
136
+ end
137
+
138
+ it "parses a positive amount with a thousands separator" do
139
+ expect(@parser.parse("100.000,00")).to eq(Money.new(100_000.00))
140
+ end
141
+
142
+ it "parses a negative amount with a thousands separator" do
143
+ expect(@parser.parse("-100.000,00")).to eq(Money.new(-100_000.00))
144
+ end
145
+
146
+ it "parses amount with 3 decimals and 0 dollar amount" do
147
+ expect(@parser.parse("0,123")).to eq(Money.new(0.12))
148
+ end
149
+
150
+ it "parses negative amount with 3 decimals and 0 dollar amount" do
151
+ expect(@parser.parse("-0,123")).to eq(Money.new(-0.12))
152
+ end
153
+ end
154
+
155
+ describe "parsing of decimal cents amounts from 0 to 10" do
156
+ before(:each) do
157
+ @parser = AccountingMoneyParser.new
158
+ end
159
+
160
+ it "parses 50.0" do
161
+ expect(@parser.parse("50.0")).to eq(Money.new(50.00))
162
+ end
163
+
164
+ it "parses 50.1" do
165
+ expect(@parser.parse("50.1")).to eq(Money.new(50.10))
166
+ end
167
+
168
+ it "parses 50.2" do
169
+ expect(@parser.parse("50.2")).to eq(Money.new(50.20))
170
+ end
171
+
172
+ it "parses 50.3" do
173
+ expect(@parser.parse("50.3")).to eq(Money.new(50.30))
174
+ end
175
+
176
+ it "parses 50.4" do
177
+ expect(@parser.parse("50.4")).to eq(Money.new(50.40))
178
+ end
179
+
180
+ it "parses 50.5" do
181
+ expect(@parser.parse("50.5")).to eq(Money.new(50.50))
182
+ end
183
+
184
+ it "parses 50.6" do
185
+ expect(@parser.parse("50.6")).to eq(Money.new(50.60))
186
+ end
187
+
188
+ it "parses 50.7" do
189
+ expect(@parser.parse("50.7")).to eq(Money.new(50.70))
190
+ end
191
+
192
+ it "parses 50.8" do
193
+ expect(@parser.parse("50.8")).to eq(Money.new(50.80))
194
+ end
195
+
196
+ it "parses 50.9" do
197
+ expect(@parser.parse("50.9")).to eq(Money.new(50.90))
198
+ end
199
+
200
+ it "parses 50.10" do
201
+ expect(@parser.parse("50.10")).to eq(Money.new(50.10))
202
+ end
203
+ end
204
+ end