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
@@ -0,0 +1,58 @@
1
+ require 'json'
2
+ require_relative 'text-utils'
3
+
4
+ module BrazilianUtils
5
+ # Utilities for looking up Brazilian municipalities by IBGE code, sourced
6
+ # from the IBGE Localidades API (5,571 municipalities).
7
+ module MunicipalityUtils
8
+ DATA_FILE = File.join(File.dirname(__FILE__), 'data', 'municipalities.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.to_symbolized(row)
19
+ { code: row['code'], name: row['name'], stateCode: row['stateCode'] }
20
+ end
21
+
22
+ private_class_method :to_symbolized
23
+
24
+ # Looks a municipality up by its 7-digit IBGE code.
25
+ #
26
+ # @param code [String, Integer]
27
+ # @return [Hash, nil]
28
+ def self.get_by_code(code)
29
+ return nil if code.nil?
30
+
31
+ str = code.to_s.gsub(/\D/, '')
32
+ return nil unless str.length == 7
33
+
34
+ row = load_data.find { |r| r['code'] == str }
35
+ row && to_symbolized(row)
36
+ end
37
+
38
+ # Returns Brazilian municipalities, sorted by name (pt-BR collation).
39
+ #
40
+ # @param state_code [String, nil] Only that state's municipalities; when
41
+ # omitted, every municipality. An empty or unknown code returns an
42
+ # empty list (matching is case-sensitive).
43
+ # @return [Array<Hash>]
44
+ def self.list(state_code = nil)
45
+ rows = load_data
46
+
47
+ unless state_code.nil?
48
+ return [] unless state_code.is_a?(String) && !state_code.empty?
49
+
50
+ rows = rows.select { |r| r['stateCode'] == state_code }
51
+ end
52
+
53
+ rows
54
+ .sort_by { |r| TextUtils.remove_accents(r['name']).downcase }
55
+ .map { |r| to_symbolized(r) }
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,91 @@
1
+ require 'json'
2
+
3
+ module BrazilianUtils
4
+ # Utilities for the NCM (Nomenclatura Comum do Mercosul) table, the
5
+ # current 8-digit ("leaf") code list published by Siscomex.
6
+ module NCMUtils
7
+ DATA_FILE = File.join(File.dirname(__FILE__), 'data', 'ncm.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.apply_mask(digits, group_sizes, separators)
18
+ chunks = []
19
+ idx = 0
20
+ group_sizes.each do |size|
21
+ break if idx >= digits.length
22
+
23
+ chunks << digits[idx, size]
24
+ idx += size
25
+ end
26
+ chunks.each_with_index.map { |c, i| i.zero? ? c : "#{separators[i - 1]}#{c}" }.join
27
+ end
28
+
29
+ private_class_method :apply_mask
30
+
31
+ # Normalizes an NCM value to its bare 8 digits: bare digits (string or
32
+ # integer) are left-padded with zeros to 8; the `NNNN.NN.NN` mask is
33
+ # read as written. Any other string is rejected.
34
+ #
35
+ # @private
36
+ def self.normalize(value)
37
+ return nil if value.nil?
38
+ return value.to_s.rjust(8, '0') if value.is_a?(Integer)
39
+ return nil unless value.is_a?(String)
40
+
41
+ raw = value.strip
42
+ return raw.rjust(8, '0') if raw.match?(/\A\d+\z/)
43
+
44
+ m = raw.match(/\A(\d{4})\.(\d{2})\.(\d{2})\z/)
45
+ m && "#{m[1]}#{m[2]}#{m[3]}"
46
+ end
47
+
48
+ private_class_method :normalize
49
+
50
+ # Formats an NCM code with the mask `NNNN.NN.NN`; the mask is applied
51
+ # as far as the digits go (only the structure changes — use {is_valid}
52
+ # to check the code).
53
+ #
54
+ # @param value [String, Integer]
55
+ # @param options [Hash] `:pad` left-pads with zeros to 8 digits first.
56
+ # @return [String] An empty string when there is no digit at all.
57
+ def self.format(value, options = {})
58
+ digits = value.to_s.gsub(/\D/, '')
59
+ digits = digits.rjust(8, '0') if options[:pad] || options['pad']
60
+ return '' if digits.empty?
61
+
62
+ apply_mask(digits, [4, 2, 2], ['.', '.'])
63
+ end
64
+
65
+ # Checks whether an NCM code exists in the current table.
66
+ #
67
+ # @param value [String, Integer]
68
+ # @return [Boolean]
69
+ def self.is_valid(value)
70
+ code = normalize(value)
71
+ return false unless code
72
+
73
+ load_data.key?(code)
74
+ end
75
+
76
+ class << self
77
+ alias valid? is_valid
78
+ end
79
+
80
+ # Removes NCM formatting and keeps only digits, capped to 8 digits
81
+ # (nothing is left-padded).
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, 8]
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,158 @@
1
+ require_relative 'state-utils'
2
+
3
+ module BrazilianUtils
4
+ # Utilities for the 44-digit DF-e (Documento Fiscal eletrônico) access
5
+ # key shared by NF-e, NFC-e, CT-e, MDF-e, CT-e OS, GTV-e, BP-e, NF3e and
6
+ # NFCom.
7
+ #
8
+ # @note For NFCom and NF3e (models 62 and 66) the numeric code (`cNF`)
9
+ # field is 7 digits rather than 8, which shifts the rest of the key;
10
+ # no test case exercised these two models, so {get_info} always uses
11
+ # the general 8-digit layout. Its `code`/`model` fields for a 62/66 key
12
+ # should be treated as unverified.
13
+ module NfeKeyUtils
14
+ LENGTH = 44
15
+
16
+ ID_PREFIXES = %w[NFCom NF3e NFe CTe MDFe BPe].freeze
17
+
18
+ VALID_MODELS = %w[55 65 57 58 67 64 63 66 62].freeze
19
+
20
+ # @private
21
+ def self.apply_mask(digits, group_size)
22
+ digits.chars.each_slice(group_size).map(&:join).join(' ')
23
+ end
24
+
25
+ private_class_method :apply_mask
26
+
27
+ # @private
28
+ def self.strip_id_prefix(raw)
29
+ prefix = ID_PREFIXES.find { |p| raw.start_with?(p) }
30
+ prefix ? raw[prefix.length..-1] : raw
31
+ end
32
+
33
+ private_class_method :strip_id_prefix
34
+
35
+ # Removes the formatting of a DF-e access key and keeps only digits,
36
+ # capped to 44 digits. The XML `Id` prefixes are stripped first.
37
+ #
38
+ # @param value [String, Integer]
39
+ # @return [String]
40
+ def self.parse(value)
41
+ return '' unless value.is_a?(String) || value.is_a?(Integer)
42
+
43
+ raw = strip_id_prefix(value.to_s)
44
+ raw.gsub(/\D/, '')[0, LENGTH]
45
+ end
46
+
47
+ # Formats a DF-e access key into groups of 4 digits separated by
48
+ # spaces. Does not validate (use {is_valid}).
49
+ #
50
+ # @param value [String]
51
+ # @param options [Hash] `:pad` left-pads with zeros to 44 digits first.
52
+ # @return [String]
53
+ def self.format(value, options = {})
54
+ raw = value.is_a?(String) ? strip_id_prefix(value) : value.to_s
55
+ digits = raw.gsub(/\D/, '')
56
+ digits = digits.rjust(LENGTH, '0') if options[:pad] || options['pad']
57
+ return '' if digits.empty?
58
+
59
+ apply_mask(digits, 4)
60
+ end
61
+
62
+ # @private
63
+ def self.normalize(value)
64
+ return nil unless value.is_a?(String)
65
+
66
+ raw = strip_id_prefix(value.strip)
67
+ return nil if raw.empty?
68
+
69
+ digits = raw.gsub(%r{[\s.\-/]}, '')
70
+ digits.match?(/\A\d{#{LENGTH}}\z/) ? digits : nil
71
+ end
72
+
73
+ private_class_method :normalize
74
+
75
+ # @private
76
+ def self.check_digit(base43)
77
+ weights = []
78
+ w = 2
79
+ base43.length.times do
80
+ weights << w
81
+ w = w == 9 ? 2 : w + 1
82
+ end
83
+ weights.reverse!
84
+
85
+ sum = base43.chars.each_with_index.sum { |d, i| d.to_i * weights[i] }
86
+ r = sum % 11
87
+ r < 2 ? 0 : 11 - r
88
+ end
89
+
90
+ private_class_method :check_digit
91
+
92
+ # @private
93
+ def self.cnf_ok?(cnf, nnf)
94
+ return false if cnf.chars.uniq.length == 1
95
+ return false if cnf.to_i == nnf.to_i
96
+
97
+ digits = cnf.chars.map(&:to_i)
98
+ ascending = digits.each_cons(2).all? { |a, b| b == (a + 1) % 10 }
99
+ descending = digits.each_cons(2).all? { |a, b| b == (a - 1) % 10 }
100
+
101
+ !(ascending || descending)
102
+ end
103
+
104
+ private_class_method :cnf_ok?
105
+
106
+ # Validates a 44-digit DF-e access key.
107
+ #
108
+ # @param value [String]
109
+ # @return [Boolean]
110
+ def self.is_valid(value)
111
+ digits = normalize(value)
112
+ return false unless digits
113
+
114
+ cuf = digits[0, 2]
115
+ model = digits[20, 2]
116
+ nnf = digits[25, 9]
117
+ tpemis = digits[34, 1]
118
+ cnf = digits[35, 8]
119
+ cdv = digits[43, 1]
120
+
121
+ return false unless StateUtils.get_by_ibge_code(cuf)
122
+ return false unless VALID_MODELS.include?(model)
123
+ return false unless tpemis.match?(/\A[1-9]\z/)
124
+ return false if nnf.to_i.zero?
125
+ return false if %w[55 65].include?(model) && !cnf_ok?(cnf, nnf)
126
+
127
+ cdv.to_i == check_digit(digits[0, 43])
128
+ end
129
+
130
+ class << self
131
+ alias valid? is_valid
132
+ end
133
+
134
+ # Parses a DF-e access key into its fields.
135
+ #
136
+ # @param value [String]
137
+ # @return [Hash, nil] `nil` whenever {is_valid} would return false.
138
+ def self.get_info(value)
139
+ return nil unless is_valid(value)
140
+
141
+ digits = normalize(value)
142
+ state = StateUtils.get_by_ibge_code(digits[0, 2])
143
+
144
+ {
145
+ stateCode: state[:code],
146
+ year: 2000 + digits[2, 2].to_i,
147
+ month: digits[4, 2].to_i,
148
+ taxId: digits[6, 14],
149
+ model: digits[20, 2],
150
+ series: digits[22, 3].to_i,
151
+ number: digits[25, 9].to_i,
152
+ emissionType: digits[34, 1].to_i,
153
+ code: digits[35, 8],
154
+ checkDigit: digits[43, 1].to_i
155
+ }
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BrazilianUtils
4
+ # Utilities for writing numbers in Brazilian Portuguese cardinal words
5
+ # ("por extenso"), e.g. `1235` becomes `"mil duzentos e trinta e cinco"`.
6
+ module NumberUtils
7
+ MAX_ABS_VALUE = 999_999_999_999_999
8
+
9
+ FEMININE_GENDERS = %i[feminine feminino f].freeze
10
+
11
+ # Writes an integer in Brazilian Portuguese cardinal words.
12
+ #
13
+ # @param value [Numeric] The number to convert. Accepts integers from
14
+ # -999,999,999,999,999 to 999,999,999,999,999; a non-integer is
15
+ # truncated toward zero.
16
+ # @param options [Hash] `:gender` (`:masculine`, default, or
17
+ # `:feminine`) agrees "um/uma", "dois/duas" and the hundreds.
18
+ # @return [String] The textual representation, or an empty string for a
19
+ # value outside the supported range or not finite.
20
+ #
21
+ # @example
22
+ # convert_to_words(1235) #=> "mil duzentos e trinta e cinco"
23
+ # convert_to_words(100) #=> "cem"
24
+ # convert_to_words(-3) #=> "menos três"
25
+ # convert_to_words(2, gender: :feminine) #=> "duas"
26
+ def self.convert_to_words(value, options = {})
27
+ return '' unless value.is_a?(Numeric)
28
+ return '' if value.respond_to?(:finite?) && !value.finite?
29
+
30
+ int_value = value.to_i
31
+ return '' if int_value.abs > MAX_ABS_VALUE
32
+
33
+ gender = (options[:gender] || options['gender'] || :masculine).to_sym
34
+ negative = int_value.negative?
35
+ words = number_to_words(int_value.abs, gender)
36
+ negative ? "menos #{words}" : words
37
+ end
38
+
39
+ # @private
40
+ def self.number_to_words(number, gender = :masculine)
41
+ return 'zero' if number.zero?
42
+
43
+ scales = ['', 'mil', 'milhão', 'bilhão', 'trilhão']
44
+ scales_plural = ['', 'mil', 'milhões', 'bilhões', 'trilhões']
45
+
46
+ groups = []
47
+ temp = number
48
+ while temp > 0
49
+ groups << temp % 1000
50
+ temp /= 1000
51
+ end
52
+
53
+ parts = []
54
+ groups.each_with_index do |group, index|
55
+ next if group.zero?
56
+
57
+ group_text = convert_group(group, gender)
58
+ scale_name = group == 1 ? scales[index] : scales_plural[index]
59
+
60
+ parts << if scale_name.empty?
61
+ group_text
62
+ elsif index == 1 && group == 1
63
+ scale_name
64
+ else
65
+ "#{group_text} #{scale_name}"
66
+ end
67
+ end
68
+
69
+ parts.reverse!
70
+ return parts.first if parts.length == 1
71
+
72
+ final_group = groups.find { |g| !g.zero? }
73
+ last = parts.pop
74
+ separator = (final_group < 100 || (final_group % 100).zero?) ? ' e ' : ' '
75
+ "#{parts.join(' ')}#{separator}#{last}"
76
+ end
77
+
78
+ private_class_method :number_to_words
79
+
80
+ # @private
81
+ def self.convert_group(number, gender = :masculine)
82
+ feminine = FEMININE_GENDERS.include?(gender)
83
+
84
+ ones = %w[zero um dois três quatro cinco seis sete oito nove]
85
+ ones_fem = %w[zero uma duas três quatro cinco seis sete oito nove]
86
+ tens = %w[dez onze doze treze quatorze quinze dezesseis dezessete dezoito dezenove]
87
+ tens_multiples = %w[_ _ vinte trinta quarenta cinquenta sessenta setenta oitenta noventa]
88
+ hundreds_m = %w[
89
+ _ cento duzentos trezentos quatrocentos quinhentos seiscentos setecentos oitocentos novecentos
90
+ ]
91
+ hundreds_f = %w[
92
+ _ cento duzentas trezentas quatrocentas quinhentas seiscentas setecentas oitocentas novecentas
93
+ ]
94
+
95
+ ones_words = feminine ? ones_fem : ones
96
+ hundreds = feminine ? hundreds_f : hundreds_m
97
+
98
+ return ones_words[number] if number < 10
99
+ return tens[number - 10] if number < 20
100
+
101
+ if number < 100
102
+ tens_digit = number / 10
103
+ ones_digit = number % 10
104
+ return tens_multiples[tens_digit] if ones_digit.zero?
105
+
106
+ return "#{tens_multiples[tens_digit]} e #{ones_words[ones_digit]}"
107
+ end
108
+
109
+ hundreds_digit = number / 100
110
+ remainder = number % 100
111
+
112
+ if number == 100
113
+ 'cem'
114
+ elsif remainder.zero?
115
+ hundreds[hundreds_digit]
116
+ else
117
+ "#{hundreds[hundreds_digit]} e #{convert_group(remainder, gender)}"
118
+ end
119
+ end
120
+
121
+ private_class_method :convert_group
122
+ end
123
+ end
@@ -0,0 +1,65 @@
1
+ module BrazilianUtils
2
+ # Utilities for formatting, validating and generating Brazilian passport
3
+ # numbers: 2 letters followed by 6 digits. There is no check digit.
4
+ module PassportUtils
5
+ # Removes the formatting symbols (`-`, `.`, `/` and whitespace),
6
+ # keeping everything else.
7
+ #
8
+ # @param value [String]
9
+ # @return [String]
10
+ def self.remove_symbols(value)
11
+ return '' unless value.is_a?(String)
12
+
13
+ value.gsub(%r{[-./\s]}, '')
14
+ end
15
+
16
+ # Removes every non-alphanumeric character from a passport number,
17
+ # upper-cases it and caps it to 8 characters.
18
+ #
19
+ # @param value [String]
20
+ # @return [String]
21
+ #
22
+ # @example
23
+ # parse("Ab123456") #=> "AB123456"
24
+ # parse(" AB 123 456 ") #=> "AB123456"
25
+ def self.parse(value)
26
+ return '' unless value.is_a?(String)
27
+
28
+ value.gsub(/[^a-zA-Z0-9]/, '').upcase[0, 8]
29
+ end
30
+
31
+ # Formats a passport number: the same operation as {parse}.
32
+ #
33
+ # @param value [String]
34
+ # @return [String]
35
+ def self.format(value)
36
+ parse(value)
37
+ end
38
+
39
+ # Validates a Brazilian passport number: 2 letters followed by 6
40
+ # digits, after removing non-alphanumeric characters.
41
+ #
42
+ # @param value [String, Integer]
43
+ # @return [Boolean]
44
+ def self.is_valid(value)
45
+ return false unless value.is_a?(String) || value.is_a?(Integer)
46
+
47
+ cleaned = value.to_s.gsub(/[^a-zA-Z0-9]/, '')
48
+ cleaned.match?(/\A[A-Za-z]{2}\d{6}\z/)
49
+ end
50
+
51
+ class << self
52
+ alias valid? is_valid
53
+ end
54
+
55
+ # Generates a random valid passport number: 2 uppercase letters
56
+ # followed by 6 digits.
57
+ #
58
+ # @return [String]
59
+ def self.generate
60
+ letters = 2.times.map { ('A'..'Z').to_a.sample }.join
61
+ digits = 6.times.map { rand(0..9) }.join
62
+ "#{letters}#{digits}"
63
+ end
64
+ end
65
+ end