br-utils 0.1.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 (40) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +120 -0
  3. data/.rspec +2 -0
  4. data/.travis.yml +5 -0
  5. data/Gemfile +3 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +348 -0
  8. data/Rakefile +6 -0
  9. data/examples/boleto_usage_example.rb +79 -0
  10. data/examples/cep_usage_example.rb +148 -0
  11. data/examples/cnh_usage_example.rb +120 -0
  12. data/examples/cnpj_usage_example.rb +227 -0
  13. data/examples/cpf_usage_example.rb +237 -0
  14. data/examples/currency_usage_example.rb +266 -0
  15. data/examples/date_usage_example.rb +259 -0
  16. data/examples/email_usage_example.rb +321 -0
  17. data/examples/legal_nature_usage_example.rb +437 -0
  18. data/examples/legal_process_usage_example.rb +444 -0
  19. data/examples/license_plate_usage_example.rb +440 -0
  20. data/examples/phone_usage_example.rb +595 -0
  21. data/examples/pis_usage_example.rb +588 -0
  22. data/examples/renavam_usage_example.rb +499 -0
  23. data/examples/voter_id_usage_example.rb +573 -0
  24. data/lib/brazilian-utils/boleto-utils.rb +176 -0
  25. data/lib/brazilian-utils/cep-utils.rb +330 -0
  26. data/lib/brazilian-utils/cnh-utils.rb +88 -0
  27. data/lib/brazilian-utils/cnpj-utils.rb +202 -0
  28. data/lib/brazilian-utils/cpf-utils.rb +192 -0
  29. data/lib/brazilian-utils/currency-utils.rb +226 -0
  30. data/lib/brazilian-utils/data/legal_process_ids.json +38 -0
  31. data/lib/brazilian-utils/date-utils.rb +244 -0
  32. data/lib/brazilian-utils/email-utils.rb +54 -0
  33. data/lib/brazilian-utils/legal-nature-utils.rb +235 -0
  34. data/lib/brazilian-utils/legal-process-utils.rb +240 -0
  35. data/lib/brazilian-utils/license-plate-utils.rb +279 -0
  36. data/lib/brazilian-utils/phone-utils.rb +272 -0
  37. data/lib/brazilian-utils/pis-utils.rb +151 -0
  38. data/lib/brazilian-utils/renavam-utils.rb +113 -0
  39. data/lib/brazilian-utils/voter-id-utils.rb +165 -0
  40. metadata +123 -0
@@ -0,0 +1,272 @@
1
+ module BrazilianUtils
2
+ # Utilities for formatting, validating, and generating Brazilian phone numbers.
3
+ #
4
+ # Brazilian phone numbers come in two types:
5
+ # - Mobile (Celular): 11 digits - DDD (2 digits) + 9 + 8 digits, e.g., "11994029275"
6
+ # - Landline (Fixo): 10 digits - DDD (2 digits) + [2-5] + 7 digits, e.g., "1635014415"
7
+ #
8
+ # DDD (Discagem Direta à Distância) is the area code, ranging from 11 to 99.
9
+ # Mobile numbers always have 9 as the 3rd digit (after DDD).
10
+ # Landline numbers have 2, 3, 4, or 5 as the 3rd digit (after DDD).
11
+ module PhoneUtils
12
+ # Pattern for mobile phone numbers (11 digits: DDD + 9 + 8 digits)
13
+ MOBILE_PATTERN = /^[1-9][1-9][9]\d{8}$/.freeze
14
+
15
+ # Pattern for landline phone numbers (10 digits: DDD + [2-5] + 7 digits)
16
+ LANDLINE_PATTERN = /^[1-9][1-9][2-5]\d{7}$/.freeze
17
+
18
+ # Pattern for international dialing code (+55 or 55)
19
+ INTERNATIONAL_CODE_PATTERN = /\+?55/.freeze
20
+
21
+ # Formats a Brazilian phone number into the standard pattern.
22
+ #
23
+ # Formats as (DD)NNNNN-NNNN for both mobile and landline numbers.
24
+ #
25
+ # @param phone [String] A string representing the phone number (digits only)
26
+ #
27
+ # @return [String, nil] The formatted phone number or nil if invalid
28
+ #
29
+ # @example
30
+ # format_phone("11994029275")
31
+ # #=> "(11)99402-9275"
32
+ #
33
+ # format_phone("1635014415")
34
+ # #=> "(16)3501-4415"
35
+ #
36
+ # format_phone("333333")
37
+ # #=> nil
38
+ def self.format_phone(phone)
39
+ return nil unless is_valid(phone)
40
+
41
+ ddd = phone[0..1]
42
+ phone_number = phone[2..-1]
43
+
44
+ "(#{ddd})#{phone_number[0..-5]}-#{phone_number[-4..-1]}"
45
+ end
46
+
47
+ # Alias for format_phone
48
+ class << self
49
+ alias format format_phone
50
+ end
51
+
52
+ # Returns if a Brazilian phone number is valid.
53
+ #
54
+ # It does not verify if the number actually exists.
55
+ #
56
+ # @param phone_number [String] The phone number to validate (digits only, no country code)
57
+ # @param type [Symbol, String, nil] :mobile, :landline, "mobile", or "landline".
58
+ # If not specified, checks for either type.
59
+ #
60
+ # @return [Boolean] True if the phone number is valid, false otherwise
61
+ #
62
+ # @example
63
+ # is_valid("11994029275")
64
+ # #=> true (mobile)
65
+ #
66
+ # is_valid("1635014415")
67
+ # #=> true (landline)
68
+ #
69
+ # is_valid("11994029275", :mobile)
70
+ # #=> true
71
+ #
72
+ # is_valid("1635014415", :mobile)
73
+ # #=> false
74
+ #
75
+ # is_valid("1635014415", :landline)
76
+ # #=> true
77
+ #
78
+ # is_valid("123456")
79
+ # #=> false
80
+ def self.is_valid(phone_number, type = nil)
81
+ return false unless phone_number.is_a?(String)
82
+
83
+ type_str = type.to_s if type
84
+
85
+ case type_str
86
+ when 'mobile'
87
+ valid_mobile?(phone_number)
88
+ when 'landline'
89
+ valid_landline?(phone_number)
90
+ else
91
+ valid_mobile?(phone_number) || valid_landline?(phone_number)
92
+ end
93
+ end
94
+
95
+ # Alias for is_valid
96
+ class << self
97
+ alias valid? is_valid
98
+ end
99
+
100
+ # Removes common symbols from a Brazilian phone number string.
101
+ #
102
+ # Removes: (, ), -, +, and spaces
103
+ #
104
+ # @param phone_number [String] The phone number to remove symbols from
105
+ #
106
+ # @return [String] A new string with the specified symbols removed
107
+ #
108
+ # @example
109
+ # remove_symbols_phone("(11)99402-9275")
110
+ # #=> "11994029275"
111
+ #
112
+ # remove_symbols_phone("+55 11 99402-9275")
113
+ # #=> "5511994029275"
114
+ #
115
+ # remove_symbols_phone("(16) 3501-4415")
116
+ # #=> "1635014415"
117
+ def self.remove_symbols_phone(phone_number)
118
+ return '' unless phone_number.is_a?(String)
119
+
120
+ phone_number.gsub(/[\(\)\-\+\s]/, '')
121
+ end
122
+
123
+ # Alias for remove_symbols_phone
124
+ class << self
125
+ alias remove_symbols remove_symbols_phone
126
+ alias sieve remove_symbols_phone
127
+ end
128
+
129
+ # Generates a valid and random phone number.
130
+ #
131
+ # @param type [Symbol, String, nil] :mobile, :landline, "mobile", or "landline".
132
+ # If not specified, generates either type randomly.
133
+ #
134
+ # @return [String] A randomly generated valid phone number
135
+ #
136
+ # @example
137
+ # generate
138
+ # #=> "2234451215" (random type)
139
+ #
140
+ # generate(:mobile)
141
+ # #=> "11999115895"
142
+ #
143
+ # generate(:landline)
144
+ # #=> "1635317900"
145
+ #
146
+ # generate("mobile")
147
+ # #=> "21987654321"
148
+ def self.generate(type = nil)
149
+ type_str = type.to_s if type
150
+
151
+ case type_str
152
+ when 'mobile'
153
+ generate_mobile_phone
154
+ when 'landline'
155
+ generate_landline_phone
156
+ else
157
+ [method(:generate_mobile_phone), method(:generate_landline_phone)].sample.call
158
+ end
159
+ end
160
+
161
+ # Removes the international dialing code (+55 or 55) from a phone number.
162
+ #
163
+ # Only removes the code if the resulting number has more than 11 digits.
164
+ #
165
+ # @param phone_number [String] The phone number with or without international code
166
+ #
167
+ # @return [String] The phone number without international code, or the same number if no code present
168
+ #
169
+ # @example
170
+ # remove_international_dialing_code("5511994029275")
171
+ # #=> "11994029275"
172
+ #
173
+ # remove_international_dialing_code("+5511994029275")
174
+ # #=> "11994029275"
175
+ #
176
+ # remove_international_dialing_code("1635014415")
177
+ # #=> "1635014415" (no international code)
178
+ #
179
+ # remove_international_dialing_code("+55 11 99402-9275")
180
+ # #=> "+55 11 99402-9275" (has spaces, length check fails)
181
+ def self.remove_international_dialing_code(phone_number)
182
+ return '' unless phone_number.is_a?(String)
183
+
184
+ # Check if pattern matches and length (without spaces) is > 11
185
+ if INTERNATIONAL_CODE_PATTERN.match?(phone_number) && phone_number.gsub(' ', '').length > 11
186
+ phone_number.sub('55', '')
187
+ else
188
+ phone_number
189
+ end
190
+ end
191
+
192
+ # Returns if a Brazilian mobile number is valid.
193
+ #
194
+ # Mobile pattern: DDD (2 digits 1-9) + 9 + 8 digits (total 11 digits)
195
+ #
196
+ # @param phone_number [String] The mobile number to validate
197
+ #
198
+ # @return [Boolean] True if valid mobile, false otherwise
199
+ #
200
+ # @private
201
+ def self.valid_mobile?(phone_number)
202
+ return false unless phone_number.is_a?(String)
203
+
204
+ MOBILE_PATTERN.match?(phone_number.strip)
205
+ end
206
+
207
+ private_class_method :valid_mobile?
208
+
209
+ # Returns if a Brazilian landline number is valid.
210
+ #
211
+ # Landline pattern: DDD (2 digits 1-9) + [2-5] + 7 digits (total 10 digits)
212
+ #
213
+ # @param phone_number [String] The landline number to validate
214
+ #
215
+ # @return [Boolean] True if valid landline, false otherwise
216
+ #
217
+ # @private
218
+ def self.valid_landline?(phone_number)
219
+ return false unless phone_number.is_a?(String)
220
+
221
+ LANDLINE_PATTERN.match?(phone_number.strip)
222
+ end
223
+
224
+ private_class_method :valid_landline?
225
+
226
+ # Generates a valid DDD (area code) number.
227
+ #
228
+ # DDD consists of 2 digits, both ranging from 1-9.
229
+ #
230
+ # @return [String] A 2-digit DDD number
231
+ #
232
+ # @private
233
+ def self.generate_ddd_number
234
+ 2.times.map { rand(1..9) }.join
235
+ end
236
+
237
+ private_class_method :generate_ddd_number
238
+
239
+ # Generates a valid and random mobile phone number.
240
+ #
241
+ # Format: DDD + 9 + 8 random digits (total 11 digits)
242
+ #
243
+ # @return [String] A valid mobile phone number
244
+ #
245
+ # @private
246
+ def self.generate_mobile_phone
247
+ ddd = generate_ddd_number
248
+ client_number = 8.times.map { rand(0..9) }.join
249
+
250
+ "#{ddd}9#{client_number}"
251
+ end
252
+
253
+ private_class_method :generate_mobile_phone
254
+
255
+ # Generates a valid and random landline phone number.
256
+ #
257
+ # Format: DDD + [2-5] + 7 random digits (total 10 digits)
258
+ #
259
+ # @return [String] A valid landline phone number
260
+ #
261
+ # @private
262
+ def self.generate_landline_phone
263
+ ddd = generate_ddd_number
264
+ first_digit = rand(2..5)
265
+ remaining_digits = rand(0..9999999).to_s.rjust(7, '0')
266
+
267
+ "#{ddd}#{first_digit}#{remaining_digits}"
268
+ end
269
+
270
+ private_class_method :generate_landline_phone
271
+ end
272
+ end
@@ -0,0 +1,151 @@
1
+ module BrazilianUtils
2
+ # Utilities for formatting, validating, and generating Brazilian PIS numbers.
3
+ #
4
+ # PIS (Programa de Integração Social) is an 11-digit identification number
5
+ # for Brazilian workers, similar to a social security number.
6
+ #
7
+ # Format: XXX.XXXXX.XX-X (e.g., "123.45678.90-9")
8
+ module PISUtils
9
+ # Weights used for checksum calculation
10
+ WEIGHTS = [3, 2, 9, 8, 7, 6, 5, 4, 3, 2].freeze
11
+
12
+ # Removes formatting symbols from a PIS string.
13
+ #
14
+ # This function takes a PIS string with formatting symbols (dots and hyphens)
15
+ # and returns a cleaned version with only digits.
16
+ #
17
+ # @param pis [String] A PIS string that may contain formatting symbols
18
+ #
19
+ # @return [String] A cleaned PIS string with no formatting symbols
20
+ #
21
+ # @example
22
+ # remove_symbols("123.45678.90-9")
23
+ # #=> "12345678909"
24
+ #
25
+ # remove_symbols("987.65432.10-0")
26
+ # #=> "98765432100"
27
+ #
28
+ # remove_symbols("12345678909")
29
+ # #=> "12345678909"
30
+ def self.remove_symbols(pis)
31
+ return '' unless pis.is_a?(String)
32
+
33
+ pis.gsub(/[.\-]/, '')
34
+ end
35
+
36
+ # Alias for remove_symbols
37
+ class << self
38
+ alias sieve remove_symbols
39
+ end
40
+
41
+ # Formats a valid PIS string with standard visual aid symbols.
42
+ #
43
+ # This function takes a valid numbers-only PIS string as input
44
+ # and adds standard formatting visual aid symbols for display.
45
+ #
46
+ # Format: XXX.XXXXX.XX-X
47
+ #
48
+ # @param pis [String] A valid numbers-only PIS string (11 digits)
49
+ #
50
+ # @return [String, nil] A formatted PIS string or nil if invalid
51
+ #
52
+ # @example
53
+ # format_pis("12345678909")
54
+ # #=> "123.45678.90-9"
55
+ #
56
+ # format_pis("98765432100")
57
+ # #=> "987.65432.10-0"
58
+ #
59
+ # format_pis("123456789")
60
+ # #=> nil (invalid)
61
+ def self.format_pis(pis)
62
+ return nil unless is_valid(pis)
63
+
64
+ "#{pis[0..2]}.#{pis[3..7]}.#{pis[8..9]}-#{pis[10]}"
65
+ end
66
+
67
+ # Alias for format_pis
68
+ class << self
69
+ alias format format_pis
70
+ end
71
+
72
+ # Returns whether the verifying checksum digit of the given PIS matches its base number.
73
+ #
74
+ # This function validates that:
75
+ # - The input is a string
76
+ # - The length is exactly 11 digits
77
+ # - All characters are digits
78
+ # - The checksum digit (last digit) is correct
79
+ #
80
+ # @param pis [String] PIS number as a string (11 digits)
81
+ #
82
+ # @return [Boolean] True if PIS is valid, false otherwise
83
+ #
84
+ # @example
85
+ # is_valid("12345678909")
86
+ # #=> true
87
+ #
88
+ # is_valid("123.45678.90-9")
89
+ # #=> false (contains symbols, use remove_symbols first)
90
+ #
91
+ # is_valid("12345678900")
92
+ # #=> false (invalid checksum)
93
+ #
94
+ # is_valid("123456789")
95
+ # #=> false (wrong length)
96
+ def self.is_valid(pis)
97
+ return false unless pis.is_a?(String)
98
+ return false unless pis.length == 11
99
+ return false unless pis.match?(/^\d+$/)
100
+
101
+ pis[-1] == checksum(pis[0..9]).to_s
102
+ end
103
+
104
+ # Alias for is_valid
105
+ class << self
106
+ alias valid? is_valid
107
+ end
108
+
109
+ # Generates a random valid Brazilian PIS number.
110
+ #
111
+ # This function generates a random PIS number with the following characteristics:
112
+ # - It has 11 digits
113
+ # - It passes the weight calculation check
114
+ #
115
+ # @return [String] A randomly generated valid PIS number as a string
116
+ #
117
+ # @example
118
+ # generate
119
+ # #=> "12345678909" (example, actual value is random)
120
+ #
121
+ # generate
122
+ # #=> "98765432100" (example)
123
+ def self.generate
124
+ base = rand(0..9_999_999_999).to_s.rjust(10, '0')
125
+ base + checksum(base).to_s
126
+ end
127
+
128
+ # Calculates the checksum digit of the given base PIS string.
129
+ #
130
+ # The checksum algorithm:
131
+ # 1. Multiply each of the first 10 digits by corresponding weight
132
+ # 2. Sum all products
133
+ # 3. Calculate: 11 - (sum % 11)
134
+ # 4. If result is 10 or 11, use 0 instead
135
+ #
136
+ # @param base_pis [String] The first 10 digits of a PIS number
137
+ #
138
+ # @return [Integer] The checksum digit (0-9)
139
+ #
140
+ # @private
141
+ def self.checksum(base_pis)
142
+ pis_digits = base_pis.chars.map(&:to_i)
143
+ pis_sum = pis_digits.zip(WEIGHTS).sum { |digit, weight| digit * weight }
144
+ check_digit = 11 - (pis_sum % 11)
145
+
146
+ [10, 11].include?(check_digit) ? 0 : check_digit
147
+ end
148
+
149
+ private_class_method :checksum
150
+ end
151
+ end
@@ -0,0 +1,113 @@
1
+ module BrazilianUtils
2
+ # Utilities for validating Brazilian RENAVAM numbers.
3
+ #
4
+ # RENAVAM (Registro Nacional de Veículos Automotores) is an 11-digit
5
+ # identification number for motor vehicles in Brazil.
6
+ #
7
+ # The last digit is a verification digit calculated from the first 10 digits.
8
+ module RENAVAMUtils
9
+ # Weights used for verification digit calculation
10
+ # Applied to the first 10 digits in reverse order
11
+ DV_WEIGHTS = [2, 3, 4, 5, 6, 7, 8, 9, 2, 3].freeze
12
+
13
+ # Validates the Brazilian vehicle registration number (RENAVAM).
14
+ #
15
+ # This function takes a RENAVAM string and checks if it is valid.
16
+ # A valid RENAVAM consists of exactly 11 digits, with the last digit as
17
+ # a verification digit calculated from the previous 10 digits.
18
+ #
19
+ # @param renavam [String] The RENAVAM string to be validated
20
+ #
21
+ # @return [Boolean] True if the RENAVAM is valid, false otherwise
22
+ #
23
+ # @example
24
+ # is_valid_renavam("86769597308")
25
+ # #=> true
26
+ #
27
+ # is_valid_renavam("12345678901")
28
+ # #=> false
29
+ #
30
+ # is_valid_renavam("1234567890a")
31
+ # #=> false (contains letter)
32
+ #
33
+ # is_valid_renavam("12345678 901")
34
+ # #=> false (contains space)
35
+ #
36
+ # is_valid_renavam("12345678")
37
+ # #=> false (wrong length)
38
+ #
39
+ # is_valid_renavam("")
40
+ # #=> false
41
+ def self.is_valid_renavam(renavam)
42
+ return false unless validate_renavam_format(renavam)
43
+
44
+ calculate_renavam_dv(renavam) == renavam[-1].to_i
45
+ end
46
+
47
+ # Alias for is_valid_renavam
48
+ class << self
49
+ alias is_valid is_valid_renavam
50
+ alias valid? is_valid_renavam
51
+ end
52
+
53
+ # Validates the format of a RENAVAM string.
54
+ #
55
+ # Checks:
56
+ # - Must be a string
57
+ # - Must be exactly 11 digits
58
+ # - Cannot be all the same digit (e.g., "11111111111")
59
+ #
60
+ # @param renavam [String] The RENAVAM string to validate
61
+ #
62
+ # @return [Boolean] True if format is valid, false otherwise
63
+ #
64
+ # @private
65
+ def self.validate_renavam_format(renavam)
66
+ return false unless renavam.is_a?(String)
67
+ return false unless renavam.length == 11
68
+ return false unless renavam.match?(/^\d+$/)
69
+ return false if renavam.chars.uniq.length == 1 # All same digit
70
+
71
+ true
72
+ end
73
+
74
+ private_class_method :validate_renavam_format
75
+
76
+ # Sums the weighted digits of a RENAVAM.
77
+ #
78
+ # Takes the first 10 digits, reverses them, multiplies each by the
79
+ # corresponding weight, and returns the sum.
80
+ #
81
+ # @param renavam [String] The RENAVAM string
82
+ #
83
+ # @return [Integer] The sum of weighted digits
84
+ #
85
+ # @private
86
+ def self.sum_weighted_digits(renavam)
87
+ base_digits = renavam[0..9].reverse.chars.map(&:to_i)
88
+ base_digits.zip(DV_WEIGHTS).sum { |digit, weight| digit * weight }
89
+ end
90
+
91
+ private_class_method :sum_weighted_digits
92
+
93
+ # Calculates the verification digit for a RENAVAM.
94
+ #
95
+ # Algorithm:
96
+ # 1. Sum the weighted digits (first 10 digits reversed, multiplied by weights)
97
+ # 2. Calculate: 11 - (sum % 11)
98
+ # 3. If result >= 10, return 0, otherwise return the result
99
+ #
100
+ # @param renavam [String] The RENAVAM string
101
+ #
102
+ # @return [Integer] The verification digit (0-9)
103
+ #
104
+ # @private
105
+ def self.calculate_renavam_dv(renavam)
106
+ weighted_sum = sum_weighted_digits(renavam)
107
+ dv = 11 - (weighted_sum % 11)
108
+ dv >= 10 ? 0 : dv
109
+ end
110
+
111
+ private_class_method :calculate_renavam_dv
112
+ end
113
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BrazilianUtils
4
+ module VoterIdUtils
5
+ # UF codes mapping to federative union numbers
6
+ UF_CODES = {
7
+ 'SP' => '01', 'MG' => '02', 'RJ' => '03', 'RS' => '04',
8
+ 'BA' => '05', 'PR' => '06', 'CE' => '07', 'PE' => '08',
9
+ 'SC' => '09', 'GO' => '10', 'MA' => '11', 'PB' => '12',
10
+ 'PA' => '13', 'ES' => '14', 'PI' => '15', 'RN' => '16',
11
+ 'AL' => '17', 'MT' => '18', 'MS' => '19', 'DF' => '20',
12
+ 'SE' => '21', 'AM' => '22', 'RO' => '23', 'AC' => '24',
13
+ 'AP' => '25', 'RR' => '26', 'TO' => '27', 'ZZ' => '28'
14
+ }.freeze
15
+
16
+ module_function
17
+
18
+ # Check if a Brazilian voter ID number is valid.
19
+ #
20
+ # @param voter_id [String] The voter ID to validate
21
+ # @return [Boolean] true if valid, false otherwise
22
+ def is_valid_voter_id(voter_id)
23
+ # Ensure voter_id is a string with only digits and valid length
24
+ return false unless voter_id.is_a?(String)
25
+ return false unless voter_id.match?(/^\d+$/)
26
+ return false unless is_length_valid?(voter_id)
27
+
28
+ # Extract parts (using negative indexing for federative union and verifying digits)
29
+ sequential_number = get_sequential_number(voter_id)
30
+ federative_union = get_federative_union(voter_id)
31
+ verifying_digits = get_verifying_digits(voter_id)
32
+
33
+ # Validate federative union
34
+ return false unless is_federative_union_valid?(federative_union)
35
+
36
+ # Validate first verifying digit
37
+ vd1 = calculate_vd1(sequential_number, federative_union)
38
+ return false if vd1 != verifying_digits[0].to_i
39
+
40
+ # Validate second verifying digit
41
+ vd2 = calculate_vd2(federative_union, vd1)
42
+ return false if vd2 != verifying_digits[1].to_i
43
+
44
+ true
45
+ end
46
+
47
+ # Alias for is_valid_voter_id
48
+ alias valid_voter_id? is_valid_voter_id
49
+
50
+ # Format a voter ID for display with visual spaces.
51
+ #
52
+ # @param voter_id [String] The voter ID to format
53
+ # @return [String, nil] Formatted voter ID or nil if invalid
54
+ def format_voter_id(voter_id)
55
+ return nil unless is_valid_voter_id(voter_id)
56
+
57
+ "#{voter_id[0..3]} #{voter_id[4..7]} #{voter_id[8..9]} #{voter_id[10..11]}"
58
+ end
59
+
60
+ # Alias for format_voter_id
61
+ alias format format_voter_id
62
+
63
+ # Generate a random valid Brazilian voter ID.
64
+ #
65
+ # @param federative_union [String] The UF code (e.g., "SP", "MG", "ZZ")
66
+ # @return [String, nil] A valid voter ID or nil if UF is invalid
67
+ def generate(federative_union = 'ZZ')
68
+ federative_union = federative_union.upcase
69
+ return nil unless UF_CODES.key?(federative_union)
70
+
71
+ uf_number = UF_CODES[federative_union]
72
+ return nil unless is_federative_union_valid?(uf_number)
73
+
74
+ # Generate random 8-digit sequential number
75
+ sequential_number = format('%08d', rand(0..99_999_999))
76
+
77
+ # Calculate verification digits
78
+ vd1 = calculate_vd1(sequential_number, uf_number)
79
+ vd2 = calculate_vd2(uf_number, vd1)
80
+
81
+ "#{sequential_number}#{uf_number}#{vd1}#{vd2}"
82
+ end
83
+
84
+ private
85
+
86
+ # Check if the length of the voter ID is valid.
87
+ # Typically 12 digits, but can be 13 for SP and MG (edge case).
88
+ def is_length_valid?(voter_id)
89
+ return true if voter_id.length == 12
90
+
91
+ # Edge case: SP and MG can have 13 digits
92
+ federative_union = get_federative_union(voter_id)
93
+ voter_id.length == 13 && ['01', '02'].include?(federative_union)
94
+ end
95
+
96
+ # Extract the sequential number (first 8 digits).
97
+ def get_sequential_number(voter_id)
98
+ voter_id[0..7]
99
+ end
100
+
101
+ # Extract the federative union (2 digits before last 2 digits).
102
+ def get_federative_union(voter_id)
103
+ voter_id[-4..-3]
104
+ end
105
+
106
+ # Extract the verifying digits (last 2 digits).
107
+ def get_verifying_digits(voter_id)
108
+ voter_id[-2..-1]
109
+ end
110
+
111
+ # Check if the federative union is valid (between '01' and '28').
112
+ def is_federative_union_valid?(federative_union)
113
+ num = federative_union.to_i
114
+ num >= 1 && num <= 28
115
+ end
116
+
117
+ # Calculate the first verifying digit.
118
+ #
119
+ # @param sequential_number [String] First 8 digits
120
+ # @param federative_union [String] 2-digit UF code
121
+ # @return [Integer] The first verification digit
122
+ def calculate_vd1(sequential_number, federative_union)
123
+ # Weights: 2, 3, 4, 5, 6, 7, 8, 9
124
+ weights = (2..9).to_a
125
+
126
+ sum = sequential_number.chars.each_with_index.sum do |digit, index|
127
+ digit.to_i * weights[index]
128
+ end
129
+
130
+ rest = sum % 11
131
+ vd1 = rest
132
+
133
+ # Edge case: rest == 0 and federative_union is SP ('01') or MG ('02')
134
+ vd1 = 1 if rest == 0 && ['01', '02'].include?(federative_union)
135
+
136
+ # Edge case: rest == 10
137
+ vd1 = 0 if rest == 10
138
+
139
+ vd1
140
+ end
141
+
142
+ # Calculate the second verifying digit.
143
+ #
144
+ # @param federative_union [String] 2-digit UF code
145
+ # @param vd1 [Integer] First verification digit
146
+ # @return [Integer] The second verification digit
147
+ def calculate_vd2(federative_union, vd1)
148
+ # Weights: 7, 8, 9
149
+ sum = (federative_union[0].to_i * 7) +
150
+ (federative_union[1].to_i * 8) +
151
+ (vd1 * 9)
152
+
153
+ rest = sum % 11
154
+ vd2 = rest
155
+
156
+ # Edge case: rest == 0 and federative_union is SP ('01') or MG ('02')
157
+ vd2 = 1 if rest == 0 && ['01', '02'].include?(federative_union)
158
+
159
+ # Edge case: rest == 10
160
+ vd2 = 0 if rest == 10
161
+
162
+ vd2
163
+ end
164
+ end
165
+ end