shopify-money 0.10.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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