br-utils 0.1.1 → 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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +446 -22
  3. data/lib/brazilian-utils/area-code-utils.rb +60 -0
  4. data/lib/brazilian-utils/bank-account-utils.rb +82 -0
  5. data/lib/brazilian-utils/bank-utils.rb +61 -0
  6. data/lib/brazilian-utils/boleto-utils.rb +139 -0
  7. data/lib/brazilian-utils/caepf-utils.rb +91 -0
  8. data/lib/brazilian-utils/cbo-utils.rb +74 -0
  9. data/lib/brazilian-utils/cei-utils.rb +79 -0
  10. data/lib/brazilian-utils/cep-utils.rb +25 -1
  11. data/lib/brazilian-utils/certidao-utils.rb +171 -0
  12. data/lib/brazilian-utils/cfop-utils.rb +74 -0
  13. data/lib/brazilian-utils/cnae-utils.rb +104 -0
  14. data/lib/brazilian-utils/cnh-utils.rb +53 -0
  15. data/lib/brazilian-utils/cno-utils.rb +76 -0
  16. data/lib/brazilian-utils/cnpj-utils.rb +125 -12
  17. data/lib/brazilian-utils/cns-utils.rb +110 -0
  18. data/lib/brazilian-utils/cpf-utils.rb +13 -0
  19. data/lib/brazilian-utils/credit-card-utils.rb +47 -0
  20. data/lib/brazilian-utils/csosn-utils.rb +55 -0
  21. data/lib/brazilian-utils/cst-utils.rb +103 -0
  22. data/lib/brazilian-utils/currency-utils.rb +347 -228
  23. data/lib/brazilian-utils/data/area_codes.json +676 -0
  24. data/lib/brazilian-utils/data/banks.json +357 -0
  25. data/lib/brazilian-utils/data/cbo.json +1 -0
  26. data/lib/brazilian-utils/data/cfop.json +1 -0
  27. data/lib/brazilian-utils/data/cnae.json +1 -0
  28. data/lib/brazilian-utils/data/csosn.json +42 -0
  29. data/lib/brazilian-utils/data/cst.json +294 -0
  30. data/lib/brazilian-utils/data/legal_nature.json +900 -0
  31. data/lib/brazilian-utils/data/municipalities.json +1 -0
  32. data/lib/brazilian-utils/data/ncm.json +1 -0
  33. data/lib/brazilian-utils/data/states.json +218 -0
  34. data/lib/brazilian-utils/date-utils.rb +504 -256
  35. data/lib/brazilian-utils/iban-utils.rb +120 -0
  36. data/lib/brazilian-utils/ie-utils.rb +84 -0
  37. data/lib/brazilian-utils/legal-nature-utils.rb +238 -235
  38. data/lib/brazilian-utils/legal-process-utils.rb +31 -3
  39. data/lib/brazilian-utils/license-plate-utils.rb +21 -5
  40. data/lib/brazilian-utils/municipality-utils.rb +58 -0
  41. data/lib/brazilian-utils/ncm-utils.rb +91 -0
  42. data/lib/brazilian-utils/nfe-key-utils.rb +158 -0
  43. data/lib/brazilian-utils/number-utils.rb +123 -0
  44. data/lib/brazilian-utils/passport-utils.rb +65 -0
  45. data/lib/brazilian-utils/phone-utils.rb +491 -280
  46. data/lib/brazilian-utils/pis-utils.rb +16 -1
  47. data/lib/brazilian-utils/pix-key-utils.rb +70 -0
  48. data/lib/brazilian-utils/pix-payload-utils.rb +253 -0
  49. data/lib/brazilian-utils/registro-profissional-utils.rb +112 -0
  50. data/lib/brazilian-utils/renavam-utils.rb +14 -0
  51. data/lib/brazilian-utils/state-utils.rb +105 -0
  52. data/lib/brazilian-utils/text-utils.rb +105 -0
  53. data/lib/brazilian-utils/vin-utils.rb +46 -0
  54. data/lib/brazilian-utils/voter-id-utils.rb +36 -0
  55. metadata +39 -1
@@ -0,0 +1,82 @@
1
+ require_relative 'bank-utils'
2
+
3
+ module BrazilianUtils
4
+ # Utilities for validating Brazilian bank accounts (bank code, agency,
5
+ # account and check digit).
6
+ #
7
+ # @note This implementation validates the account's *structure* (a known
8
+ # COMPE `bankCode`, agency/account digit-count limits, an allowed
9
+ # `digit` character) and a generic modulus 10 / modulus 11 fallback
10
+ # check digit. The distinct, published check-digit algorithms some
11
+ # banks use (Banco do Brasil, Santander, Banrisul, Caixa, Bradesco,
12
+ # Nubank/Verhoeff, Itaú, HSBC/Kirton, Citibank) are **not** implemented:
13
+ # there were no verifiable test vectors available to confirm a from-
14
+ # scratch reproduction of each algorithm, and shipping an unverified
15
+ # guess would be worse than this honestly-partial generic check.
16
+ module BankAccountUtils
17
+ # @private
18
+ def self.mod10_digit(digits)
19
+ sum = 0
20
+ digits.reverse.each_char.with_index do |ch, i|
21
+ d = ch.to_i
22
+ d *= 2 if i.even?
23
+ d -= 9 if d > 9
24
+ sum += d
25
+ end
26
+ (10 - (sum % 10)) % 10
27
+ end
28
+
29
+ private_class_method :mod10_digit
30
+
31
+ # @private
32
+ def self.mod11_digit(digits)
33
+ weights = [2, 3, 4, 5, 6, 7, 8, 9]
34
+ sum = 0
35
+ digits.reverse.each_char.with_index do |ch, i|
36
+ sum += ch.to_i * weights[i % weights.length]
37
+ end
38
+ rest = 11 - (sum % 11)
39
+ rest >= 10 ? 0 : rest
40
+ end
41
+
42
+ private_class_method :mod11_digit
43
+
44
+ # Validates a Brazilian bank account.
45
+ #
46
+ # @param params [Hash] `:bankCode` (3 digits), `:agency` (1-5 digits),
47
+ # `:account` (1-13 digits) and `:digit` (1-2 characters, or the
48
+ # literal `X`/`P` used by some banks), all strings.
49
+ # @return [Boolean]
50
+ def self.is_valid(params)
51
+ return false unless params.is_a?(Hash)
52
+
53
+ bank_code = params[:bankCode] || params['bankCode']
54
+ agency = params[:agency] || params['agency']
55
+ account = params[:account] || params['account']
56
+ digit = params[:digit] || params['digit']
57
+
58
+ return false unless [bank_code, agency, account, digit].all? { |v| v.is_a?(String) }
59
+ return false unless BankUtils.get_by_code(bank_code)
60
+ return false unless agency.match?(/\A\d{1,5}\z/)
61
+ return false unless account.match?(/\A\d{1,13}\z/)
62
+ return false unless digit.match?(/\A[0-9A-Za-z]{1,2}\z/)
63
+
64
+ digit_upcase = digit.upcase
65
+ return true if %w[X P].include?(digit_upcase) && digit.length == 1
66
+
67
+ return false unless digit.match?(/\A\d{1,2}\z/)
68
+
69
+ if digit.length == 2
70
+ first_ok = digit[0].to_i == mod10_digit(account)
71
+ second_ok = digit[1].to_i == mod11_digit(account + digit[0])
72
+ first_ok && second_ok
73
+ else
74
+ digit.to_i == mod10_digit(account) || digit.to_i == mod11_digit(account)
75
+ end
76
+ end
77
+
78
+ class << self
79
+ alias valid? is_valid
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,61 @@
1
+ require 'json'
2
+
3
+ module BrazilianUtils
4
+ # Utilities for looking up Brazilian banks by COMPE code or ISPB, sourced
5
+ # from the Banco Central do Brasil STR participants list.
6
+ module BankUtils
7
+ DATA_FILE = File.join(File.dirname(__FILE__), 'data', 'banks.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
+ # @private
17
+ def self.to_symbolized(row)
18
+ { code: row['code'], ispb: row['ispb'], name: row['name'] }
19
+ end
20
+
21
+ private_class_method :to_symbolized
22
+
23
+ # Looks a bank up by its 3-digit COMPE code.
24
+ #
25
+ # @param code [String, Integer] A number is read as the zero-padded
26
+ # 3-digit code.
27
+ # @return [Hash, nil]
28
+ def self.get_by_code(code)
29
+ return nil if code.nil?
30
+
31
+ str = code.to_s.strip
32
+ return nil unless str.match?(/\A\d+\z/)
33
+
34
+ padded = str.rjust(3, '0')
35
+ row = load_data.find { |r| r['code'] == padded }
36
+ row && to_symbolized(row)
37
+ end
38
+
39
+ # Looks a bank up by its 8-digit ISPB.
40
+ #
41
+ # @param value [String, Integer] With or without leading zeros.
42
+ # @return [Hash, nil]
43
+ def self.get_by_ispb(value)
44
+ return nil if value.nil?
45
+
46
+ str = value.to_s.strip
47
+ return nil unless str.match?(/\A\d+\z/)
48
+
49
+ padded = str.rjust(8, '0')
50
+ row = load_data.find { |r| r['ispb'] == padded }
51
+ row && to_symbolized(row)
52
+ end
53
+
54
+ # Returns every bank with a COMPE code.
55
+ #
56
+ # @return [Array<Hash>]
57
+ def self.list
58
+ load_data.map { |row| to_symbolized(row) }
59
+ end
60
+ end
61
+ end
@@ -1,7 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'date'
4
+
3
5
  module BrazilianUtils
4
6
  module BoletoUtils
7
+ # The fator de vencimento epoch (day 1000) before the 2025-02-22 cycle
8
+ # reset, and how many days apart the two candidate dates for a given
9
+ # factor are, per the contract's own description. Not independently
10
+ # verified against a reference implementation (no test cases were
11
+ # available for `boleto.getInfo`).
12
+ FATOR_VENCIMENTO_OLD_EPOCH = Date.new(1997, 10, 7)
13
+ FATOR_VENCIMENTO_CYCLE_GAP_DAYS = 9000
5
14
  # Every Digitable Line from Boleto has exactly 47 characters
6
15
  DIGITABLE_LINE_LENGTH = 47
7
16
 
@@ -52,6 +61,136 @@ module BrazilianUtils
52
61
  # Alias for is_valid
53
62
  alias valid? is_valid
54
63
 
64
+ # Removes boleto formatting and keeps only digits, capped to 47
65
+ # digits (48 for a boleto de arrecadação, recognized by a leading
66
+ # `8`).
67
+ #
68
+ # @param value [String, Integer]
69
+ # @return [String]
70
+ def parse(value)
71
+ return '' unless value.is_a?(String) || value.is_a?(Integer)
72
+
73
+ digits = value.to_s.gsub(/\D/, '')
74
+ return '' if digits.empty?
75
+
76
+ cap = digits[0] == '8' ? 48 : 47
77
+ digits[0, cap]
78
+ end
79
+
80
+ # @private
81
+ def apply_mask(digits, group_sizes, separators)
82
+ chunks = []
83
+ idx = 0
84
+ group_sizes.each do |size|
85
+ break if idx >= digits.length
86
+
87
+ chunks << digits[idx, size]
88
+ idx += size
89
+ end
90
+ chunks.each_with_index.map { |c, i| i.zero? ? c : "#{separators[i - 1]}#{c}" }.join
91
+ end
92
+
93
+ # Formats a boleto linha digitável with its printed mask.
94
+ #
95
+ # @param value [String, Integer]
96
+ # @param options [Hash] `:pad` left-pads the value with zeros to the
97
+ # length of the pattern before masking.
98
+ # @return [String]
99
+ def format(value, options = {})
100
+ digits = value.to_s.gsub(/\D/, '')
101
+ pad = options[:pad] || options['pad']
102
+
103
+ if pad
104
+ target = digits[0] == '8' ? 48 : 47
105
+ digits = digits.rjust(target, '0')
106
+ end
107
+
108
+ return '' if digits.empty?
109
+
110
+ if digits.length == 48 && digits[0] == '8'
111
+ digits.chars.each_slice(12).map { |b| b.join }.map do |block|
112
+ block.length > 11 ? "#{block[0, 11]}-#{block[11]}" : block
113
+ end.join(' ')
114
+ else
115
+ apply_mask(digits, [5, 5, 5, 6, 5, 6, 1, 14], ['.', ' ', '.', ' ', '.', ' ', ' '])
116
+ end
117
+ end
118
+
119
+ # Generates a valid random boleto number.
120
+ #
121
+ # @param params [Hash] `:type` set to `"arrecadacao"` generates a
122
+ # 48-digit boleto de arrecadação instead of the default 47-digit
123
+ # cobrança bancária linha digitável.
124
+ # @return [String, nil] `nil` for `type: "arrecadacao"`: it is not
125
+ # implemented (this module's {is_valid} itself only recognizes the
126
+ # 47-digit cobrança bancária form, so a generated arrecadação
127
+ # number could not be verified to round-trip through it).
128
+ def generate(params = {})
129
+ type = params[:type] || params['type']
130
+ return nil if type.to_s == 'arrecadacao'
131
+
132
+ banco = sprintf('%03d', rand(1..999))
133
+ moeda = '9'
134
+ campo_livre = 25.times.map { rand(0..9) }.join
135
+ fator_vencimento = sprintf('%04d', rand(1000..9999))
136
+ valor = sprintf('%010d', rand(0..9_999_999_999))
137
+
138
+ campo1_free = campo_livre[0, 5]
139
+ campo2 = campo_livre[5, 10]
140
+ campo3 = campo_livre[15, 10]
141
+
142
+ campo1 = "#{banco}#{moeda}#{campo1_free}"
143
+ dv1 = get_mod10(campo1)
144
+ dv2 = get_mod10(campo2)
145
+ dv3 = get_mod10(campo3)
146
+
147
+ barcode_without_dv = "#{banco}#{moeda}#{fator_vencimento}#{valor}#{campo_livre}"
148
+ dv_geral = get_mod11(barcode_without_dv)
149
+
150
+ "#{campo1}#{dv1}#{campo2}#{dv2}#{campo3}#{dv3}#{dv_geral}#{fator_vencimento}#{valor}"
151
+ end
152
+
153
+ # Extracts the amount, due date and bank code from a boleto.
154
+ #
155
+ # @note Only the 47-digit cobrança bancária form is supported; a
156
+ # boleto de arrecadação (48-digit linha digitável or 44-digit
157
+ # barcode) returns nil. The due-date resolution (fator de
158
+ # vencimento, including the 2025-02-22 cycle reset) has no
159
+ # available test cases and is unverified against a reference
160
+ # implementation.
161
+ #
162
+ # @param value [String]
163
+ # @param options [Hash] `:referenceDate` resolves the fator de
164
+ # vencimento cycle as of that date (default: today).
165
+ # @return [Hash, nil]
166
+ def get_info(value, options = {})
167
+ return nil unless is_valid(value)
168
+
169
+ digits = extract_only_numbers(value)
170
+ return nil unless digits.length == DIGITABLE_LINE_LENGTH
171
+
172
+ barcode = parse_digitable_line(digits)
173
+ bank_code = barcode[0, 3]
174
+ fator_vencimento = barcode[5, 4].to_i
175
+ amount_cents = barcode[9, 10].to_i
176
+
177
+ reference_date = options[:referenceDate] || options['referenceDate'] || Date.today
178
+ reference_date = reference_date.to_date if reference_date.respond_to?(:to_date)
179
+
180
+ due_date =
181
+ if fator_vencimento >= 1000
182
+ date_a = FATOR_VENCIMENTO_OLD_EPOCH + (fator_vencimento - 1000)
183
+ date_b = date_a + FATOR_VENCIMENTO_CYCLE_GAP_DAYS
184
+ (date_a - reference_date).abs <= (date_b - reference_date).abs ? date_a : date_b
185
+ end
186
+
187
+ {
188
+ bankCode: bank_code,
189
+ amount: amount_cents,
190
+ dueDate: due_date
191
+ }
192
+ end
193
+
55
194
  private
56
195
 
57
196
  # Extract only numeric characters from a string.
@@ -0,0 +1,91 @@
1
+ module BrazilianUtils
2
+ # Utilities for the CAEPF (Cadastro de Atividade Econômica da Pessoa
3
+ # Física): 14 digits — a 9-digit CPF base, a 3-digit sequence and 2 check
4
+ # digits.
5
+ module CAEPFUtils
6
+ # @private
7
+ def self.apply_mask(digits, group_sizes, separators)
8
+ chunks = []
9
+ idx = 0
10
+ group_sizes.each do |size|
11
+ break if idx >= digits.length
12
+
13
+ chunks << digits[idx, size]
14
+ idx += size
15
+ end
16
+ chunks.each_with_index.map { |c, i| i.zero? ? c : "#{separators[i - 1]}#{c}" }.join
17
+ end
18
+
19
+ private_class_method :apply_mask
20
+
21
+ # Computes a CNPJ-style modulus 11 check digit (the same algorithm
22
+ # `CNPJUtils` uses) over an arbitrary-length base.
23
+ #
24
+ # @private
25
+ def self.hashdigit(base, position)
26
+ weights = []
27
+ (position - 8).downto(2) { |w| weights << w }
28
+ 9.downto(2) { |w| weights << w }
29
+
30
+ val = base.chars.first(position - 1).zip(weights).sum { |d, w| d.to_i * w } % 11
31
+ val < 2 ? 0 : 11 - val
32
+ end
33
+
34
+ private_class_method :hashdigit
35
+
36
+ # @private
37
+ def self.check_digits(base12)
38
+ first = hashdigit(base12, 13)
39
+ second = hashdigit(base12 + first.to_s, 14)
40
+ ((first * 10 + second) + 12) % 100
41
+ end
42
+
43
+ private_class_method :check_digits
44
+
45
+ # Validates a CAEPF: 14 digits, with both check digits following the
46
+ # CNPJ modulus 11 rule, shifted by 12 (wrapping around 100).
47
+ #
48
+ # @param value [String, Integer] Masked as `000.000.000/000-00` or not.
49
+ # @return [Boolean]
50
+ def self.is_valid(value)
51
+ return false unless value.is_a?(String) || value.is_a?(Integer)
52
+
53
+ raw = value.to_s.strip
54
+ return false unless raw.match?(%r{\A[\d\s.\-/]+\z})
55
+
56
+ digits = raw.gsub(%r{[\s.\-/]}, '')
57
+ return false unless digits.match?(/\A\d{14}\z/)
58
+ return false if digits[0, 9].chars.uniq.length == 1
59
+
60
+ digits[12, 2].to_i == check_digits(digits[0, 12])
61
+ end
62
+
63
+ class << self
64
+ alias valid? is_valid
65
+ end
66
+
67
+ # Formats a CAEPF with the mask `000.000.000/000-00`, applied as far as
68
+ # the digits go.
69
+ #
70
+ # @param value [String, Integer]
71
+ # @param options [Hash] `:pad` left-pads with zeros to 14 digits first.
72
+ # @return [String]
73
+ def self.format(value, options = {})
74
+ digits = value.to_s.gsub(/\D/, '')
75
+ digits = digits.rjust(14, '0') if options[:pad] || options['pad']
76
+ return '' if digits.empty?
77
+
78
+ apply_mask(digits, [3, 3, 3, 3, 2], ['.', '.', '/', '-'])
79
+ end
80
+
81
+ # Removes CAEPF formatting and keeps only digits, capped to 14 digits.
82
+ #
83
+ # @param value [String, Integer]
84
+ # @return [String]
85
+ def self.parse(value)
86
+ return '' unless value.is_a?(String) || value.is_a?(Integer)
87
+
88
+ value.to_s.gsub(/\D/, '')[0, 14]
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,74 @@
1
+ require 'json'
2
+
3
+ module BrazilianUtils
4
+ # Utilities for the CBO (Classificação Brasileira de Ocupações) 2002
5
+ # occupation table (6-digit "ocupação" granularity).
6
+ module CBOUtils
7
+ DATA_FILE = File.join(File.dirname(__FILE__), 'data', 'cbo.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
+ # Normalizes a CBO value to its bare 6 digits: bare digits (string or
17
+ # integer) are left-padded with zeros to 6; the `NNNN-NN` mask (a
18
+ # single separator between the groups) is read as written. Any other
19
+ # string is rejected (its digits are not picked out).
20
+ #
21
+ # @private
22
+ def self.normalize(value)
23
+ return nil if value.nil?
24
+ return value.to_s.rjust(6, '0') if value.is_a?(Integer)
25
+ return nil unless value.is_a?(String)
26
+
27
+ raw = value.strip
28
+ return raw.rjust(6, '0') if raw.match?(/\A\d+\z/)
29
+
30
+ m = raw.match(%r{\A(\d{4})[ .\-/](\d{2})\z})
31
+ m && "#{m[1]}#{m[2]}"
32
+ end
33
+
34
+ private_class_method :normalize
35
+
36
+ # Checks whether a CBO code exists in the official table.
37
+ #
38
+ # @param value [String, Integer]
39
+ # @return [Boolean]
40
+ def self.is_valid(value)
41
+ code = normalize(value)
42
+ return false unless code
43
+
44
+ load_data.key?(code)
45
+ end
46
+
47
+ class << self
48
+ alias valid? is_valid
49
+ end
50
+
51
+ # Looks a CBO code up and returns its (bare, 6-digit) code and title.
52
+ #
53
+ # @param value [String, Integer]
54
+ # @return [Hash, nil]
55
+ def self.get(value)
56
+ code = normalize(value)
57
+ return nil unless code
58
+
59
+ description = load_data[code]
60
+ description && { code: code, description: description }
61
+ end
62
+
63
+ # Removes CBO formatting and keeps only digits, capped to 6 digits
64
+ # (nothing is left-padded).
65
+ #
66
+ # @param value [String, Integer]
67
+ # @return [String]
68
+ def self.parse(value)
69
+ return '' unless value.is_a?(String) || value.is_a?(Integer)
70
+
71
+ value.to_s.gsub(/\D/, '')[0, 6]
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,79 @@
1
+ module BrazilianUtils
2
+ # Utilities for the CEI (Cadastro Específico do INSS), a 12-digit
3
+ # registration number for a work/construction site or rural employer.
4
+ module CEIUtils
5
+ WEIGHTS = [7, 4, 1, 8, 5, 2, 1, 6, 3, 7, 4].freeze
6
+
7
+ # @private
8
+ def self.apply_mask(digits, group_sizes, separators)
9
+ chunks = []
10
+ idx = 0
11
+ group_sizes.each do |size|
12
+ break if idx >= digits.length
13
+
14
+ chunks << digits[idx, size]
15
+ idx += size
16
+ end
17
+ chunks.each_with_index.map { |c, i| i.zero? ? c : "#{separators[i - 1]}#{c}" }.join
18
+ end
19
+
20
+ private_class_method :apply_mask
21
+
22
+ # @private
23
+ def self.check_digit(base)
24
+ sum = base.chars.each_with_index.sum { |d, i| d.to_i * WEIGHTS[i] }
25
+ last_two = sum % 100
26
+ combined = (last_two / 10) + (last_two % 10)
27
+ (10 - (combined % 10)) % 10
28
+ end
29
+
30
+ private_class_method :check_digit
31
+
32
+ # Validates a CEI: 12 digits, 11 base digits and one check digit.
33
+ #
34
+ # @param value [String, Integer] Bare digits, or split into the printed
35
+ # groups (2, 3, 5 and 2 digits) by whitespace or the usual mask
36
+ # characters (`.`, `-`, `/`).
37
+ # @return [Boolean]
38
+ def self.is_valid(value)
39
+ return false unless value.is_a?(String) || value.is_a?(Integer)
40
+
41
+ raw = value.to_s.strip
42
+ return false unless raw.match?(%r{\A[\d\s.\-/]+\z})
43
+
44
+ digits = raw.gsub(%r{[\s.\-/]}, '')
45
+ return false unless digits.match?(/\A\d{12}\z/)
46
+ return false if digits.chars.uniq.length == 1
47
+
48
+ digits[11].to_i == check_digit(digits[0, 11])
49
+ end
50
+
51
+ class << self
52
+ alias valid? is_valid
53
+ end
54
+
55
+ # Formats a CEI with the mask `00.000.00000/00`, applied as far as the
56
+ # digits go.
57
+ #
58
+ # @param value [String, Integer]
59
+ # @param options [Hash] `:pad` left-pads with zeros to 12 digits first.
60
+ # @return [String]
61
+ def self.format(value, options = {})
62
+ digits = value.to_s.gsub(/\D/, '')
63
+ digits = digits.rjust(12, '0') if options[:pad] || options['pad']
64
+ return '' if digits.empty?
65
+
66
+ apply_mask(digits, [2, 3, 5, 2], ['.', '.', '/'])
67
+ end
68
+
69
+ # Removes CEI formatting and keeps only digits, capped to 12 digits.
70
+ #
71
+ # @param value [String, Integer]
72
+ # @return [String]
73
+ def self.parse(value)
74
+ return '' unless value.is_a?(String) || value.is_a?(Integer)
75
+
76
+ value.to_s.gsub(/\D/, '')[0, 12]
77
+ end
78
+ end
79
+ end
@@ -144,6 +144,20 @@ module BrazilianUtils
144
144
  8.times.map { rand(10) }.join
145
145
  end
146
146
 
147
+ # Removes CEP formatting and keeps only digits, capped to 8 digits.
148
+ #
149
+ # @param value [String, Integer] A CEP, with or without formatting.
150
+ # @return [String] The parsed digits.
151
+ #
152
+ # @example
153
+ # parse("01001-000") #=> "01001000"
154
+ # parse("01001000123") #=> "01001000"
155
+ def self.parse(value)
156
+ return '' unless value.is_a?(String) || value.is_a?(Integer)
157
+
158
+ value.to_s.gsub(/\D/, '')[0, 8]
159
+ end
160
+
147
161
  # API OPERATIONS
148
162
  ################
149
163
 
@@ -251,9 +265,19 @@ module BrazilianUtils
251
265
  # #=> CEPNotFound: SP - Example - Example
252
266
  #
253
267
  # @see https://viacep.com.br/
254
- def self.get_cep_information_from_address(federal_unit, city, street, raise_exceptions: false)
268
+ def self.get_cep_information_from_address(federal_unit, city = nil, street = nil, raise_exceptions: false)
255
269
  base_api_url = 'https://viacep.com.br/ws/%s/%s/%s/json/'
256
270
 
271
+ # Accept the contract's single-Hash-argument form, e.g.
272
+ # get_cep_information_from_address(state: "SP", city: "São Paulo", street: "Paulista")
273
+ if federal_unit.is_a?(Hash)
274
+ params = federal_unit
275
+ federal_unit = params[:state] || params[:uf] || params['state'] || params['uf']
276
+ city = params[:city] || params['city']
277
+ street = params[:street] || params['street']
278
+ raise_exceptions = params[:raise_exceptions] || params['raise_exceptions'] || raise_exceptions
279
+ end
280
+
257
281
  # Validate UF
258
282
  uf_code = federal_unit.to_s.upcase
259
283
  uf_name = UF.name_from_code(uf_code)