br-utils 0.1.0 → 0.2.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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +27 -0
  3. data/README.md +446 -22
  4. data/lib/brazilian-utils/area-code-utils.rb +60 -0
  5. data/lib/brazilian-utils/bank-account-utils.rb +82 -0
  6. data/lib/brazilian-utils/bank-utils.rb +61 -0
  7. data/lib/brazilian-utils/boleto-utils.rb +139 -0
  8. data/lib/brazilian-utils/caepf-utils.rb +91 -0
  9. data/lib/brazilian-utils/cbo-utils.rb +74 -0
  10. data/lib/brazilian-utils/cei-utils.rb +79 -0
  11. data/lib/brazilian-utils/cep-utils.rb +34 -1
  12. data/lib/brazilian-utils/certidao-utils.rb +171 -0
  13. data/lib/brazilian-utils/cfop-utils.rb +74 -0
  14. data/lib/brazilian-utils/cnae-utils.rb +104 -0
  15. data/lib/brazilian-utils/cnh-utils.rb +68 -15
  16. data/lib/brazilian-utils/cno-utils.rb +76 -0
  17. data/lib/brazilian-utils/cnpj-utils.rb +126 -13
  18. data/lib/brazilian-utils/cns-utils.rb +110 -0
  19. data/lib/brazilian-utils/cpf-utils.rb +14 -1
  20. data/lib/brazilian-utils/credit-card-utils.rb +47 -0
  21. data/lib/brazilian-utils/csosn-utils.rb +55 -0
  22. data/lib/brazilian-utils/cst-utils.rb +103 -0
  23. data/lib/brazilian-utils/currency-utils.rb +347 -226
  24. data/lib/brazilian-utils/data/area_codes.json +676 -0
  25. data/lib/brazilian-utils/data/banks.json +357 -0
  26. data/lib/brazilian-utils/data/cbo.json +1 -0
  27. data/lib/brazilian-utils/data/cfop.json +1 -0
  28. data/lib/brazilian-utils/data/cnae.json +1 -0
  29. data/lib/brazilian-utils/data/csosn.json +42 -0
  30. data/lib/brazilian-utils/data/cst.json +294 -0
  31. data/lib/brazilian-utils/data/legal_nature.json +900 -0
  32. data/lib/brazilian-utils/data/municipalities.json +1 -0
  33. data/lib/brazilian-utils/data/ncm.json +1 -0
  34. data/lib/brazilian-utils/data/states.json +218 -0
  35. data/lib/brazilian-utils/date-utils.rb +504 -244
  36. data/lib/brazilian-utils/email-utils.rb +20 -9
  37. data/lib/brazilian-utils/iban-utils.rb +120 -0
  38. data/lib/brazilian-utils/ie-utils.rb +84 -0
  39. data/lib/brazilian-utils/legal-nature-utils.rb +238 -235
  40. data/lib/brazilian-utils/legal-process-utils.rb +35 -5
  41. data/lib/brazilian-utils/license-plate-utils.rb +24 -4
  42. data/lib/brazilian-utils/municipality-utils.rb +58 -0
  43. data/lib/brazilian-utils/ncm-utils.rb +91 -0
  44. data/lib/brazilian-utils/nfe-key-utils.rb +158 -0
  45. data/lib/brazilian-utils/number-utils.rb +123 -0
  46. data/lib/brazilian-utils/passport-utils.rb +65 -0
  47. data/lib/brazilian-utils/phone-utils.rb +491 -272
  48. data/lib/brazilian-utils/pis-utils.rb +16 -1
  49. data/lib/brazilian-utils/pix-key-utils.rb +70 -0
  50. data/lib/brazilian-utils/pix-payload-utils.rb +253 -0
  51. data/lib/brazilian-utils/registro-profissional-utils.rb +112 -0
  52. data/lib/brazilian-utils/renavam-utils.rb +14 -0
  53. data/lib/brazilian-utils/state-utils.rb +105 -0
  54. data/lib/brazilian-utils/text-utils.rb +105 -0
  55. data/lib/brazilian-utils/vin-utils.rb +46 -0
  56. data/lib/brazilian-utils/voter-id-utils.rb +70 -19
  57. metadata +54 -2
  58. data/.travis.yml +0 -5
@@ -120,34 +120,123 @@ module BrazilianUtils
120
120
  # validates the format of the string.
121
121
  #
122
122
  # @param cnpj [String] The CNPJ to be validated, a 14-digit string
123
+ # (v1, numeric) or a 14-character alphanumeric string (v2, when
124
+ # `version: 2` is given).
125
+ # @param version [Integer] `1` (default) validates the classic
126
+ # all-numeric CNPJ; `2` validates the alphanumeric CNPJ introduced by
127
+ # IN RFB 2.119.
123
128
  # @return [Boolean] true if the checksum digits match the base number, false otherwise.
124
129
  #
125
130
  # @example
126
- # valid?("03560714000142") #=> true
127
- # valid?("00111222000133") #=> false
128
- def self.valid?(cnpj)
129
- cnpj.is_a?(String) && validate(cnpj)
131
+ # valid?("03560714000142") #=> true
132
+ # valid?("00111222000133") #=> false
133
+ # valid?("12ABC34501DE35", version: 2)
134
+ def self.valid?(cnpj, version: 1)
135
+ return false unless cnpj.is_a?(String)
136
+
137
+ version.to_i == 2 ? valid_v2?(cnpj) : validate(cnpj)
130
138
  end
131
139
 
132
140
  # Generates a random valid CNPJ digit string.
133
141
  #
134
- # An optional branch number parameter can be given; it defaults to 1.
142
+ # An optional branch number parameter can be given; it defaults to 1
143
+ # (v1) or a random branch 1-9999 (v2, when not given).
135
144
  #
136
- # @param branch [Integer] An optional branch number to be included in the CNPJ.
145
+ # @param branch [Integer, nil] An optional branch number to be included
146
+ # in the CNPJ.
147
+ # @param version [Integer] `1` (default) generates the classic
148
+ # all-numeric CNPJ; `2` generates the alphanumeric CNPJ.
137
149
  # @return [String] A randomly generated valid CNPJ string.
138
150
  #
139
151
  # @example
140
- # generate() #=> "30180536000105"
141
- # generate(1234) #=> "01745284123455"
142
- def self.generate(branch: 1)
143
- branch = branch % 10_000
144
- branch = 1 if branch.zero?
145
- branch_str = branch.to_s.rjust(4, '0')
152
+ # generate() #=> "30180536000105"
153
+ # generate(branch: 1234) #=> "01745284123455"
154
+ # generate(version: 2) #=> "12ABC34501DE35"
155
+ def self.generate(branch: nil, version: 1)
156
+ return generate_v2(branch) if version.to_i == 2
157
+
158
+ branch_num = branch.nil? ? 1 : branch % 10_000
159
+ branch_num = 1 if branch_num.zero?
160
+ branch_str = branch_num.to_s.rjust(4, '0')
146
161
  base = format('%08d', rand(100_000_000)) + branch_str
147
162
 
148
163
  base + checksum(base)
149
164
  end
150
165
 
166
+ # V2 (ALPHANUMERIC, IN RFB 2.119)
167
+ #################################
168
+
169
+ # Characters usable in the alphanumeric CNPJ's base (positions 0-11).
170
+ V2_CHARSET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'.freeze
171
+
172
+ V2_WEIGHTS_DV1 = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2].freeze
173
+ V2_WEIGHTS_DV2 = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2].freeze
174
+
175
+ # Maps a base-36 character (`0`-`9`, `A`-`Z`) to its numeric value for
176
+ # the v2 checksum: `'0'`..`'9'` -> 0..9, `'A'`..`'Z'` -> 17..42 (its
177
+ # ASCII code minus 48).
178
+ #
179
+ # @private
180
+ def self.v2_char_value(char)
181
+ char.ord - 48
182
+ end
183
+
184
+ private_class_method :v2_char_value
185
+
186
+ # Computes a single modulus-11 check digit over `chars` (a String of
187
+ # `0`-`9`/`A`-`Z` characters) using the given per-position weights.
188
+ #
189
+ # @private
190
+ def self.v2_hashdigit(chars, weights)
191
+ sum = chars.chars.each_with_index.sum { |c, i| v2_char_value(c) * weights[i] }
192
+ mod = sum % 11
193
+ mod < 2 ? 0 : 11 - mod
194
+ end
195
+
196
+ private_class_method :v2_hashdigit
197
+
198
+ # Validates a 14-character alphanumeric CNPJ (case-insensitive).
199
+ #
200
+ # @param cnpj [String]
201
+ # @return [Boolean]
202
+ #
203
+ # @private
204
+ def self.valid_v2?(cnpj)
205
+ upper = cnpj.to_s.upcase
206
+ return false unless upper.match?(/\A[0-9A-Z]{14}\z/)
207
+
208
+ dv1 = v2_hashdigit(upper[0, 12], V2_WEIGHTS_DV1)
209
+ return false unless dv1 == v2_char_value(upper[12])
210
+
211
+ dv2 = v2_hashdigit(upper[0, 13], V2_WEIGHTS_DV2)
212
+ dv2 == v2_char_value(upper[13])
213
+ end
214
+
215
+ private_class_method :valid_v2?
216
+
217
+ # Generates a random valid alphanumeric (v2) CNPJ.
218
+ #
219
+ # @param branch [Integer, nil] An optional branch number (1-9999); a
220
+ # random one is used when not given.
221
+ # @return [String]
222
+ #
223
+ # @private
224
+ def self.generate_v2(branch = nil)
225
+ branch_num = branch.nil? ? rand(1..9999) : branch % 10_000
226
+ branch_num = 1 if branch_num.zero?
227
+ branch_str = branch_num.to_s.rjust(4, '0')
228
+
229
+ base8 = Array.new(8) { V2_CHARSET[rand(V2_CHARSET.length)] }.join
230
+ base12 = base8 + branch_str
231
+
232
+ dv1 = v2_hashdigit(base12, V2_WEIGHTS_DV1)
233
+ dv2 = v2_hashdigit(base12 + dv1.to_s, V2_WEIGHTS_DV2)
234
+
235
+ "#{base12}#{dv1}#{dv2}"
236
+ end
237
+
238
+ private_class_method :generate_v2
239
+
151
240
  # PRIVATE METHODS
152
241
  #################
153
242
 
@@ -170,7 +259,7 @@ module BrazilianUtils
170
259
  (position - 8).downto(2) { |w| weights << w }
171
260
  9.downto(2) { |w| weights << w }
172
261
 
173
- val = cnpj.chars.zip(weights).sum do |digit, weight|
262
+ val = cnpj.chars.first(position - 1).zip(weights).sum do |digit, weight|
174
263
  digit.to_i * weight
175
264
  end % 11
176
265
 
@@ -198,5 +287,29 @@ module BrazilianUtils
198
287
  end
199
288
 
200
289
  private_class_method :hashdigit, :checksum
290
+
291
+ # Removes CNPJ formatting and returns the normalized value, capped to 14
292
+ # characters.
293
+ #
294
+ # @param value [String, Integer] A CNPJ, with or without formatting.
295
+ # @param options [Hash] `:version` `1` (default) keeps digits only; `2`
296
+ # keeps letters and digits, upper-cased (the alphanumeric CNPJ format).
297
+ # @return [String] The parsed value.
298
+ #
299
+ # @example
300
+ # parse("46.843.485/0001-86") #=> "46843485000186"
301
+ def self.parse(value, options = {})
302
+ return '' unless value.is_a?(String) || value.is_a?(Integer)
303
+
304
+ version = (options[:version] || options['version'] || 1).to_i
305
+
306
+ cleaned = if version == 2
307
+ value.to_s.gsub(/[^a-zA-Z0-9]/, '').upcase
308
+ else
309
+ value.to_s.gsub(/\D/, '')
310
+ end
311
+
312
+ cleaned[0, 14]
313
+ end
201
314
  end
202
315
  end
@@ -0,0 +1,110 @@
1
+ module BrazilianUtils
2
+ # Utilities for the CNS (Cartão Nacional de Saúde) number: 15 digits,
3
+ # definitive (starts with 1 or 2) or provisional (starts with 7, 8 or 9).
4
+ module CNSUtils
5
+ # @private
6
+ def self.apply_mask(digits, group_sizes, separators)
7
+ chunks = []
8
+ idx = 0
9
+ group_sizes.each do |size|
10
+ break if idx >= digits.length
11
+
12
+ chunks << digits[idx, size]
13
+ idx += size
14
+ end
15
+ chunks.each_with_index.map { |c, i| i.zero? ? c : "#{separators[i - 1]}#{c}" }.join
16
+ end
17
+
18
+ private_class_method :apply_mask
19
+
20
+ # @private
21
+ def self.weighted_sum(digits, weights)
22
+ digits.chars.each_with_index.sum { |d, i| d.to_i * weights[i] }
23
+ end
24
+
25
+ private_class_method :weighted_sum
26
+
27
+ # @private
28
+ def self.definitive_valid?(digits)
29
+ weights = (5..15).to_a.reverse # [15, 14, ..., 5]
30
+ pis = digits[0, 11]
31
+
32
+ sum = weighted_sum(pis, weights)
33
+ resto = sum % 11
34
+ dv = 11 - resto
35
+ dv = 0 if dv == 11
36
+
37
+ if dv == 10
38
+ pis2 = (pis.to_i + 1).to_s.rjust(11, '0')
39
+ sum2 = weighted_sum(pis2, weights)
40
+ resto2 = sum2 % 11
41
+ dv2 = 11 - resto2
42
+ dv2 = 0 if dv2 >= 10
43
+ digits == "#{pis2}001#{dv2}"
44
+ else
45
+ digits == "#{pis}000#{dv}"
46
+ end
47
+ end
48
+
49
+ private_class_method :definitive_valid?
50
+
51
+ # @private
52
+ def self.provisional_valid?(digits)
53
+ weights = (1..15).to_a.reverse # [15, 14, ..., 1]
54
+ (weighted_sum(digits, weights) % 11).zero?
55
+ end
56
+
57
+ private_class_method :provisional_valid?
58
+
59
+ # Validates a CNS number: 15 digits.
60
+ #
61
+ # @param value [String, Integer] The bare digits, or the printed
62
+ # 3-4-4-4 groups split by whitespace, `.`, `-` or `/`.
63
+ # @return [Boolean]
64
+ def self.is_valid(value)
65
+ return false unless value.is_a?(String) || value.is_a?(Integer)
66
+
67
+ raw = value.to_s.strip
68
+ return false unless raw.match?(%r{\A[\d\s.\-/]+\z})
69
+
70
+ digits = raw.gsub(%r{[\s.\-/]}, '')
71
+ return false unless digits.match?(/\A\d{15}\z/)
72
+
73
+ case digits[0]
74
+ when '1', '2'
75
+ definitive_valid?(digits)
76
+ when '7', '8', '9'
77
+ provisional_valid?(digits)
78
+ else
79
+ false
80
+ end
81
+ end
82
+
83
+ class << self
84
+ alias valid? is_valid
85
+ end
86
+
87
+ # Formats a CNS number into 3-4-4-4 groups separated by spaces.
88
+ #
89
+ # @param value [String, Integer]
90
+ # @param options [Hash] `:pad` left-pads with zeros to 15 digits first.
91
+ # @return [String]
92
+ def self.format(value, options = {})
93
+ digits = value.to_s.gsub(/\D/, '')
94
+ digits = digits.rjust(15, '0') if options[:pad] || options['pad']
95
+ return '' if digits.empty?
96
+
97
+ apply_mask(digits, [3, 4, 4, 4], [' ', ' ', ' '])
98
+ end
99
+
100
+ # Removes CNS formatting and keeps only digits, capped to 15 digits.
101
+ #
102
+ # @param value [String, Integer]
103
+ # @return [String]
104
+ def self.parse(value)
105
+ return '' unless value.is_a?(String) || value.is_a?(Integer)
106
+
107
+ value.to_s.gsub(/\D/, '')[0, 15]
108
+ end
109
+ end
110
+ end
@@ -161,7 +161,7 @@ module BrazilianUtils
161
161
  #
162
162
  # @private
163
163
  def self.hashdigit(cpf, position)
164
- val = cpf.chars.zip(position.downto(2)).sum do |digit, weight|
164
+ val = cpf.chars.first(position - 1).zip(position.downto(2)).sum do |digit, weight|
165
165
  digit.to_i * weight
166
166
  end % 11
167
167
 
@@ -188,5 +188,18 @@ module BrazilianUtils
188
188
  end
189
189
 
190
190
  private_class_method :hashdigit, :checksum
191
+
192
+ # Removes CPF formatting and keeps only digits, capped to 11 digits.
193
+ #
194
+ # @param value [String, Integer] A CPF, with or without formatting.
195
+ # @return [String] The parsed digits.
196
+ #
197
+ # @example
198
+ # parse("943.895.751-04") #=> "94389575104"
199
+ def self.parse(value)
200
+ return '' unless value.is_a?(String) || value.is_a?(Integer)
201
+
202
+ value.to_s.gsub(/\D/, '')[0, 11]
203
+ end
191
204
  end
192
205
  end
@@ -0,0 +1,47 @@
1
+ module BrazilianUtils
2
+ # Utilities for validating a payment card number (credit or debit) by its
3
+ # structure and Luhn check digit. No brand detection, issuer range
4
+ # lookup, expiry or CVV check is performed.
5
+ module CreditCardUtils
6
+ # Validates a payment card number: 12 to 19 digits and a Luhn check
7
+ # digit.
8
+ #
9
+ # @param value [String, Integer] Whitespace, `.`, `-` and `/` are
10
+ # allowed anywhere between the digits; letters make it invalid. Pass
11
+ # a large number as a string to avoid floating-point precision loss.
12
+ # @return [Boolean] A number whose digits are all the same is rejected
13
+ # even though it passes Luhn.
14
+ def self.is_valid(value)
15
+ return false unless value.is_a?(String) || value.is_a?(Integer)
16
+
17
+ raw = value.to_s.strip
18
+ return false unless raw.match?(%r{\A[\d\s.\-/]+\z})
19
+
20
+ digits = raw.gsub(%r{[\s.\-/]}, '')
21
+ return false unless digits.match?(/\A\d{12,19}\z/)
22
+ return false if digits.chars.uniq.length == 1
23
+
24
+ luhn_valid?(digits)
25
+ end
26
+
27
+ class << self
28
+ alias valid? is_valid
29
+ end
30
+
31
+ # @private
32
+ def self.luhn_valid?(digits)
33
+ sum = 0
34
+ digits.reverse.each_char.with_index do |ch, i|
35
+ d = ch.to_i
36
+ if i.odd?
37
+ d *= 2
38
+ d -= 9 if d > 9
39
+ end
40
+ sum += d
41
+ end
42
+ (sum % 10).zero?
43
+ end
44
+
45
+ private_class_method :luhn_valid?
46
+ end
47
+ end
@@ -0,0 +1,55 @@
1
+ require 'json'
2
+
3
+ module BrazilianUtils
4
+ # Utilities for the CSOSN (Código de Situação da Operação no Simples
5
+ # Nacional) table (Ajuste SINIEF 07/2005).
6
+ module CSOSNUtils
7
+ DATA_FILE = File.join(File.dirname(__FILE__), 'data', 'csosn.json')
8
+
9
+ # @private
10
+ def self.load_data
11
+ @data ||= JSON.parse(File.read(DATA_FILE))
12
+ end
13
+
14
+ private_class_method :load_data
15
+
16
+ # Checks whether a value is one of the 10 official CSOSN codes.
17
+ #
18
+ # A CSOSN has no printed grouping/mask, so a value with any separator
19
+ # is rejected.
20
+ #
21
+ # @param value [String, Integer]
22
+ # @return [Boolean]
23
+ def self.is_valid(value)
24
+ return false if value.nil?
25
+
26
+ str = value.to_s.strip
27
+ return false unless str.match?(/\A\d{3}\z/)
28
+
29
+ load_data.any? { |row| row['code'] == str }
30
+ end
31
+
32
+ class << self
33
+ alias valid? is_valid
34
+ end
35
+
36
+ # Looks up the description of a CSOSN code.
37
+ #
38
+ # @param value [String, Integer]
39
+ # @return [String, nil]
40
+ def self.get_description(value)
41
+ return nil if value.nil?
42
+
43
+ str = value.to_s.strip
44
+ row = load_data.find { |r| r['code'] == str }
45
+ row && row['description']
46
+ end
47
+
48
+ # Returns all 10 official CSOSN entries.
49
+ #
50
+ # @return [Array<Hash>]
51
+ def self.list
52
+ load_data.map { |row| { code: row['code'], description: row['description'] } }
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,103 @@
1
+ require 'json'
2
+
3
+ module BrazilianUtils
4
+ # Utilities for the CST (Código de Situação Tributária) tables: ICMS
5
+ # (Tabela A "origem" + Tabela B "tributação"), IPI and PIS/COFINS (which
6
+ # share a table).
7
+ module CSTUtils
8
+ DATA_FILE = File.join(File.dirname(__FILE__), 'data', 'cst.json')
9
+
10
+ # @private
11
+ def self.load_data
12
+ @data ||= JSON.parse(File.read(DATA_FILE))
13
+ end
14
+
15
+ private_class_method :load_data
16
+
17
+ # @private
18
+ def self.icms_origins
19
+ load_data['icmsOrigin']['entries'].map { |e| e['code'] }
20
+ end
21
+
22
+ # @private
23
+ def self.icms_csts
24
+ load_data['icmsCst']['entries'].map { |e| e['code'] }
25
+ end
26
+
27
+ # @private
28
+ def self.ipi_csts
29
+ load_data['ipiCst']['entries'].map { |e| e['code'] }
30
+ end
31
+
32
+ # @private
33
+ def self.pis_cofins_csts
34
+ load_data['pisCofinsCst']['entries'].map { |e| e['code'] }
35
+ end
36
+
37
+ private_class_method :icms_origins, :icms_csts, :ipi_csts, :pis_cofins_csts
38
+
39
+ # Checks whether a CST code is valid for a tax.
40
+ #
41
+ # @param value [String, Integer] A string or a non-negative integer. The
42
+ # 3-digit ICMS form (origin + Tabela B code) may have a single
43
+ # separator right after the origin digit (e.g. `"1-10"`). A single
44
+ # digit is padded to the 3-digit ICMS form; a 2-digit string is
45
+ # checked as a Tabela B / IPI / PIS-COFINS code.
46
+ # @param options [Hash] `:tax` picks the table: `"icms"`, `"ipi"`,
47
+ # `"pis"` or `"cofins"` (PIS and COFINS share a table). Omitted or
48
+ # unknown, every table is accepted.
49
+ # @return [Boolean]
50
+ def self.is_valid(value, options = {})
51
+ return false if value.nil?
52
+
53
+ tax = (options[:tax] || options['tax']).to_s.downcase
54
+ tax = nil if tax.empty? || !%w[icms ipi pis cofins].include?(tax)
55
+
56
+ raw = value.to_s.strip
57
+ return false if raw.empty?
58
+
59
+ three_digit, two_digit = normalize(raw)
60
+
61
+ icms_ok = !three_digit.nil? && icms_origins.include?(three_digit[0]) && icms_csts.include?(three_digit[1, 2])
62
+
63
+ case tax
64
+ when 'icms'
65
+ icms_ok
66
+ when 'ipi'
67
+ !two_digit.nil? && ipi_csts.include?(two_digit)
68
+ when 'pis', 'cofins'
69
+ !two_digit.nil? && pis_cofins_csts.include?(two_digit)
70
+ else
71
+ icms_ok ||
72
+ (!two_digit.nil? && (icms_csts.include?(two_digit) || ipi_csts.include?(two_digit) || pis_cofins_csts.include?(two_digit)))
73
+ end
74
+ end
75
+
76
+ class << self
77
+ alias valid? is_valid
78
+ end
79
+
80
+ # Normalizes a raw CST value into a candidate 3-digit ICMS form and/or a
81
+ # candidate 2-digit (Tabela B / IPI / PIS-COFINS) form.
82
+ #
83
+ # @return [Array(String, nil), Array(nil, String), Array(nil, nil)]
84
+ #
85
+ # @private
86
+ def self.normalize(raw)
87
+ case raw
88
+ when /\A\d\z/
89
+ [raw.rjust(3, '0'), nil]
90
+ when /\A\d{3}\z/
91
+ [raw, nil]
92
+ when /\A\d[-.]\d{2}\z/
93
+ [raw[0] + raw[2, 2], nil]
94
+ when /\A\d{2}\z/
95
+ [nil, raw]
96
+ else
97
+ [nil, nil]
98
+ end
99
+ end
100
+
101
+ private_class_method :normalize
102
+ end
103
+ end