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
@@ -1,226 +1,347 @@
1
- require 'bigdecimal'
2
-
3
- module BrazilianUtils
4
- module CurrencyUtils
5
- # Formats a numeric value as Brazilian currency (R$).
6
- #
7
- # @param value [Float, Integer, String, BigDecimal] The numeric value to format.
8
- # @return [String, nil] Formatted currency string (e.g., "R$ 1.234,56") or nil if invalid.
9
- #
10
- # @example
11
- # format_currency(1234.56) #=> "R$ 1.234,56"
12
- # format_currency(0) #=> "R$ 0,00"
13
- # format_currency(-9876.54) #=> "R$ -9.876,54"
14
- # format_currency("invalid") #=> nil
15
- def self.format_currency(value)
16
- decimal_value = BigDecimal(value.to_s)
17
-
18
- # Format with 2 decimal places and thousands separator
19
- formatted = format('%.2f', decimal_value)
20
-
21
- # Split into integer and decimal parts
22
- integer_part, decimal_part = formatted.split('.')
23
-
24
- # Add thousands separator to integer part
25
- integer_part = integer_part.chars.reverse.each_slice(3).map(&:join).join('.').reverse
26
-
27
- # Combine with Brazilian format
28
- "R$ #{integer_part},#{decimal_part}"
29
- rescue ArgumentError, TypeError
30
- nil
31
- end
32
-
33
- # Converts a monetary value in Brazilian Reais to textual representation.
34
- #
35
- # @param amount [BigDecimal, Float, Integer, String] Monetary value to convert.
36
- # @return [String, nil] Textual representation in Brazilian Portuguese, or nil if invalid.
37
- #
38
- # @note
39
- # - Values are rounded down to 2 decimal places
40
- # - Maximum supported value is 1 quadrillion reais
41
- # - Negative values are prefixed with "Menos"
42
- #
43
- # @example
44
- # convert_real_to_text(1523.45)
45
- # #=> "Mil, quinhentos e vinte e três reais e quarenta e cinco centavos"
46
- #
47
- # convert_real_to_text(1.00)
48
- # #=> "Um real"
49
- #
50
- # convert_real_to_text(0.50)
51
- # #=> "Cinquenta centavos"
52
- #
53
- # convert_real_to_text(0.00)
54
- # #=> "Zero reais"
55
- def self.convert_real_to_text(amount)
56
- # Convert to BigDecimal and round down to 2 decimal places
57
- decimal_amount = BigDecimal(amount.to_s)
58
- decimal_amount = decimal_amount.truncate(2)
59
-
60
- # Check for invalid values
61
- return nil if decimal_amount.nan? || decimal_amount.infinite?
62
- return nil if decimal_amount.abs > BigDecimal('1000000000000000.00') # 1 quadrillion
63
-
64
- negative = decimal_amount < 0
65
- decimal_amount = decimal_amount.abs
66
-
67
- reais = decimal_amount.to_i
68
- centavos = ((decimal_amount - reais) * 100).to_i
69
-
70
- parts = []
71
-
72
- if reais > 0
73
- reais_text = number_to_words(reais)
74
- currency_text = reais == 1 ? 'real' : 'reais'
75
- conector = reais_text.match?(/lhão|lhões$/) ? 'de ' : ''
76
- parts << "#{reais_text} #{conector}#{currency_text}"
77
- end
78
-
79
- if centavos > 0
80
- centavos_text = "#{number_to_words(centavos)} #{centavos == 1 ? 'centavo' : 'centavos'}"
81
- if reais > 0
82
- parts << "e #{centavos_text}"
83
- else
84
- parts << centavos_text
85
- end
86
- end
87
-
88
- if reais == 0 && centavos == 0
89
- parts << 'Zero reais'
90
- end
91
-
92
- result = parts.join(' ')
93
- result = "Menos #{result}" if negative
94
-
95
- result.capitalize
96
- rescue ArgumentError, TypeError
97
- nil
98
- end
99
-
100
- # Converts a number to its textual representation in Brazilian Portuguese.
101
- #
102
- # @param number [Integer] The number to convert (0 to 999,999,999,999,999,999)
103
- # @return [String] The textual representation
104
- #
105
- # @private
106
- def self.number_to_words(number)
107
- return 'zero' if number.zero?
108
-
109
- # Scale names
110
- scales = [
111
- '',
112
- 'mil',
113
- 'milhão',
114
- 'bilhão',
115
- 'trilhão',
116
- 'quadrilhão'
117
- ]
118
-
119
- scales_plural = [
120
- '',
121
- 'mil',
122
- 'milhões',
123
- 'bilhões',
124
- 'trilhões',
125
- 'quadrilhões'
126
- ]
127
-
128
- # Break number into groups of 3 digits
129
- groups = []
130
- temp = number
131
- while temp > 0
132
- groups << temp % 1000
133
- temp /= 1000
134
- end
135
-
136
- result = []
137
- groups.each_with_index do |group, index|
138
- next if group.zero?
139
-
140
- group_text = convert_group(group)
141
- scale_name = group == 1 ? scales[index] : scales_plural[index]
142
-
143
- if scale_name.empty?
144
- result << group_text
145
- elsif index == 1 # "mil" doesn't need number before if it's exactly 1000
146
- if group == 1
147
- result << scale_name
148
- else
149
- result << "#{group_text} #{scale_name}"
150
- end
151
- else
152
- result << "#{group_text} #{scale_name}"
153
- end
154
- end
155
-
156
- # Join with "e" where appropriate
157
- if result.length > 1
158
- last = result.pop
159
- result_text = result.reverse.join(', ')
160
-
161
- # Check if we need "e" before the last part
162
- if number % 1000 < 100 && number % 1000 > 0
163
- "#{result_text} e #{last}"
164
- else
165
- "#{result_text}, #{last}"
166
- end
167
- else
168
- result.first || 'zero'
169
- end
170
- end
171
-
172
- # Converts a group of 3 digits (0-999) to words.
173
- #
174
- # @param number [Integer] Number between 0 and 999
175
- # @return [String] The textual representation
176
- #
177
- # @private
178
- def self.convert_group(number)
179
- ones = %w[zero um dois três quatro cinco seis sete oito nove]
180
- tens = %w[dez onze doze treze quatorze quinze dezesseis dezessete dezoito dezenove]
181
- tens_multiples = %w[_ _ vinte trinta quarenta cinquenta sessenta setenta oitenta noventa]
182
- hundreds = %w[
183
- _
184
- cento
185
- duzentos
186
- trezentos
187
- quatrocentos
188
- quinhentos
189
- seiscentos
190
- setecentos
191
- oitocentos
192
- novecentos
193
- ]
194
-
195
- return ones[number] if number < 10
196
-
197
- if number < 20
198
- return tens[number - 10]
199
- end
200
-
201
- if number < 100
202
- tens_digit = number / 10
203
- ones_digit = number % 10
204
- if ones_digit.zero?
205
- return tens_multiples[tens_digit]
206
- else
207
- return "#{tens_multiples[tens_digit]} e #{ones[ones_digit]}"
208
- end
209
- end
210
-
211
- # 100-999
212
- hundreds_digit = number / 100
213
- remainder = number % 100
214
-
215
- if number == 100
216
- 'cem'
217
- elsif remainder.zero?
218
- hundreds[hundreds_digit]
219
- else
220
- "#{hundreds[hundreds_digit]} e #{convert_group(remainder)}"
221
- end
222
- end
223
-
224
- private_class_method :number_to_words, :convert_group
225
- end
226
- end
1
+ require 'bigdecimal'
2
+
3
+ module BrazilianUtils
4
+ module CurrencyUtils
5
+ # Splits a BRL-ish amount string into its integer and decimal parts.
6
+ #
7
+ # The last `,` or `.` followed by 1 to `decimal_max` digits and then the
8
+ # end of the string is treated as the decimal separator; every other `,`
9
+ # or `.` found before it is treated as a thousands separator and removed.
10
+ #
11
+ # @param str [String] The (already symbol-stripped) amount string.
12
+ # @param decimal_max [Integer] Maximum length of a trailing decimal run.
13
+ # @return [Array(String, String, Boolean, Boolean)] integer part (digits
14
+ # only), decimal part (digits only, '' when none), whether a decimal
15
+ # separator was found, and whether the value is negative.
16
+ #
17
+ # @private
18
+ def self.split_amount(str, decimal_max)
19
+ cleaned = str.to_s.gsub(/[^\d.,\-]/, '')
20
+ negative = cleaned.start_with?('-')
21
+ cleaned = cleaned.sub(/\A-/, '')
22
+
23
+ return ['0', '', false, negative] if cleaned.empty?
24
+
25
+ last_sep_idx = cleaned.rindex(/[.,]/)
26
+ integer_part = cleaned
27
+ decimal_part = ''
28
+ has_decimal = false
29
+
30
+ if last_sep_idx
31
+ after = cleaned[(last_sep_idx + 1)..-1]
32
+ if after.length.between?(1, decimal_max) && after.match?(/\A\d+\z/)
33
+ has_decimal = true
34
+ decimal_part = after
35
+ integer_part = cleaned[0...last_sep_idx]
36
+ end
37
+ end
38
+
39
+ integer_part = integer_part.gsub(/[.,]/, '')
40
+ integer_part = '0' if integer_part.empty?
41
+
42
+ [integer_part, decimal_part, has_decimal, negative]
43
+ end
44
+
45
+ private_class_method :split_amount
46
+
47
+ # Clamps a requested decimal precision to the 0..20 range, defaulting to 2.
48
+ #
49
+ # @private
50
+ def self.clamp_precision(precision)
51
+ value = precision.nil? ? 2 : precision.to_i
52
+ value.clamp(0, 20)
53
+ end
54
+
55
+ private_class_method :clamp_precision
56
+
57
+ # Parses a Brazilian currency string (e.g. `"R$ 1.234,56"`) into a Float.
58
+ #
59
+ # A value with no thousands/decimal separator at all is read as cents
60
+ # (divided by 10 to the power of the precision); an empty string is 0.
61
+ #
62
+ # @param value [String] The value to parse.
63
+ # @param options [Hash] `:precision` sets the number of decimal places
64
+ # assumed for a separator-less value (default 2).
65
+ # @return [Float] The parsed amount.
66
+ #
67
+ # @example
68
+ # parse_currency("R$ 1.234,56") #=> 1234.56
69
+ # parse_currency("1234") #=> 12.34
70
+ # parse_currency("") #=> 0
71
+ def self.parse_currency(value, options = {})
72
+ precision = clamp_precision(options[:precision] || options['precision'])
73
+ str = value.to_s
74
+ return 0 if str.strip.empty?
75
+
76
+ integer_part, decimal_part, has_decimal, negative = split_amount(str, [precision, 2].max)
77
+
78
+ amount = if has_decimal
79
+ "#{integer_part}.#{decimal_part}".to_f
80
+ else
81
+ integer_part.to_i / (10.0**precision)
82
+ end
83
+
84
+ amount = -amount if negative
85
+ amount
86
+ end
87
+
88
+ class << self
89
+ alias parse parse_currency
90
+ end
91
+
92
+ # Formats a numeric value (or a currency-like string) as Brazilian
93
+ # currency, e.g. `1234.56` becomes `"1.234,56"`.
94
+ #
95
+ # No currency symbol is added by default; pass `options[:symbol] = true`
96
+ # to prefix the result with `"R$ "`.
97
+ #
98
+ # @param value [Float, Integer, String, BigDecimal] The value to format.
99
+ # @param options [Hash] `:precision` (default 2, clamped to 0..20) sets
100
+ # the number of decimal places; `:symbol` (default false) prefixes
101
+ # `"R$ "`.
102
+ # @return [String, nil] Formatted currency string, or nil if invalid.
103
+ #
104
+ # @example
105
+ # format_currency(1234.56) #=> "1.234,56"
106
+ # format_currency(1234.56, symbol: true) #=> "R$ 1.234,56"
107
+ # format_currency("1.234,56") #=> "1.234,56"
108
+ # format_currency("invalid") #=> nil
109
+ def self.format_currency(value, options = {})
110
+ precision = clamp_precision(options[:precision] || options['precision'])
111
+ symbol = options[:symbol] || options['symbol']
112
+
113
+ amount =
114
+ case value
115
+ when Numeric
116
+ value.to_f
117
+ when String
118
+ return nil if value.strip.empty?
119
+ return nil unless value.match?(/\d/)
120
+
121
+ integer_part, decimal_part, has_decimal, negative = split_amount(value, [precision, 2].max)
122
+ n = has_decimal ? "#{integer_part}.#{decimal_part}".to_f : integer_part.to_f
123
+ negative ? -n : n
124
+ else
125
+ return nil
126
+ end
127
+
128
+ return nil unless amount.finite?
129
+
130
+ formatted = sprintf("%.#{precision}f", amount)
131
+ sign = ''
132
+ if formatted.start_with?('-')
133
+ sign = '-'
134
+ formatted = formatted[1..-1]
135
+ end
136
+
137
+ integer_str, decimal_str = formatted.split('.')
138
+ integer_str = integer_str.chars.reverse.each_slice(3).map(&:join).join('.').reverse
139
+
140
+ result = "#{sign}#{integer_str}"
141
+ result += ",#{decimal_str}" if decimal_str
142
+ symbol ? "R$ #{result}" : result
143
+ rescue ArgumentError, TypeError, FloatDomainError
144
+ nil
145
+ end
146
+
147
+ class << self
148
+ alias format format_currency
149
+ end
150
+
151
+ # Converts a monetary value in Brazilian Reais to textual representation.
152
+ #
153
+ # @param amount [BigDecimal, Float, Integer, String] Monetary value to convert.
154
+ # @return [String, nil] Textual representation in Brazilian Portuguese
155
+ # (all lower case), or nil if invalid.
156
+ #
157
+ # @note
158
+ # - Values are truncated (not rounded) to 2 decimal places
159
+ # - Maximum supported value is 1 quadrillion reais
160
+ # - Negative values are prefixed with "menos"
161
+ #
162
+ # @example
163
+ # convert_real_to_text(1523.45)
164
+ # #=> "mil quinhentos e vinte e três reais e quarenta e cinco centavos"
165
+ #
166
+ # convert_real_to_text(1.00)
167
+ # #=> "um real"
168
+ #
169
+ # convert_real_to_text(0.50)
170
+ # #=> "cinquenta centavos"
171
+ #
172
+ # convert_real_to_text(0.00)
173
+ # #=> "zero reais"
174
+ def self.convert_real_to_text(amount)
175
+ # Convert to BigDecimal and round down to 2 decimal places
176
+ decimal_amount = BigDecimal(amount.to_s)
177
+ decimal_amount = decimal_amount.truncate(2)
178
+
179
+ # Check for invalid values
180
+ return nil if decimal_amount.nan? || decimal_amount.infinite?
181
+ return nil if decimal_amount.abs > BigDecimal('1000000000000000.00') # 1 quadrillion
182
+
183
+ negative = decimal_amount < 0
184
+ decimal_amount = decimal_amount.abs
185
+
186
+ reais = decimal_amount.to_i
187
+ centavos = ((decimal_amount - reais) * 100).to_i
188
+
189
+ parts = []
190
+
191
+ if reais > 0
192
+ reais_text = number_to_words(reais)
193
+ currency_text = reais == 1 ? 'real' : 'reais'
194
+ # "de" only applies when the text ends in "milhão(ões)"/"bilhão(ões)"/...
195
+ # on its own (e.g. "um milhão de reais"), not when it's followed by
196
+ # more words (e.g. "um milhão e um reais", no "de").
197
+ conector = reais_text.match?(/lhão$|lhões$/) ? 'de ' : ''
198
+ parts << "#{reais_text} #{conector}#{currency_text}"
199
+ end
200
+
201
+ if centavos > 0
202
+ centavos_text = "#{number_to_words(centavos)} #{centavos == 1 ? 'centavo' : 'centavos'}"
203
+ if reais > 0
204
+ parts << "e #{centavos_text}"
205
+ else
206
+ parts << centavos_text
207
+ end
208
+ end
209
+
210
+ if reais == 0 && centavos == 0
211
+ parts << 'zero reais'
212
+ end
213
+
214
+ result = parts.join(' ')
215
+ result = "menos #{result}" if negative
216
+
217
+ result
218
+ rescue ArgumentError, TypeError
219
+ nil
220
+ end
221
+
222
+ # Converts a number to its textual representation in Brazilian Portuguese.
223
+ #
224
+ # @param number [Integer] The number to convert (0 to 999,999,999,999,999,999)
225
+ # @return [String] The textual representation
226
+ #
227
+ # @private
228
+ def self.number_to_words(number)
229
+ number = number.to_i.abs
230
+ return 'zero' if number.zero?
231
+
232
+ # Scale names
233
+ scales = [
234
+ '',
235
+ 'mil',
236
+ 'milhão',
237
+ 'bilhão',
238
+ 'trilhão',
239
+ 'quadrilhão'
240
+ ]
241
+
242
+ scales_plural = [
243
+ '',
244
+ 'mil',
245
+ 'milhões',
246
+ 'bilhões',
247
+ 'trilhões',
248
+ 'quadrilhões'
249
+ ]
250
+
251
+ # Break number into groups of 3 digits, lowest order first
252
+ # (groups[0] is units-hundreds, groups[1] is thousands, ...)
253
+ groups = []
254
+ temp = number
255
+ while temp > 0
256
+ groups << temp % 1000
257
+ temp /= 1000
258
+ end
259
+
260
+ parts = []
261
+ groups.each_with_index do |group, index|
262
+ next if group.zero?
263
+
264
+ group_text = convert_group(group)
265
+ scale_name = group == 1 ? scales[index] : scales_plural[index]
266
+
267
+ parts << if scale_name.empty?
268
+ group_text
269
+ elsif index == 1 && group == 1 # "mil" doesn't need "um" before it
270
+ scale_name
271
+ else
272
+ "#{group_text} #{scale_name}"
273
+ end
274
+ end
275
+
276
+ # parts was built lowest-order group first; the spoken form reads
277
+ # highest order first (e.g. "mil duzentos e trinta e quatro", not
278
+ # "duzentos e trinta e quatro, mil").
279
+ parts.reverse!
280
+ return parts.first if parts.length == 1
281
+
282
+ # The lowest-order non-zero group (spoken last) gets an "e" in front
283
+ # when it reads as a single small/round term: below 100, or an exact
284
+ # multiple of 100 (e.g. "mil e quatrocentos", "mil e um"); otherwise
285
+ # groups are simply concatenated with a space, no comma
286
+ # (Manual de Redação da Presidência: "mil duzentos e cinquenta reais").
287
+ final_group = groups.find { |g| !g.zero? }
288
+ last = parts.pop
289
+ separator = (final_group < 100 || (final_group % 100).zero?) ? ' e ' : ' '
290
+ "#{parts.join(' ')}#{separator}#{last}"
291
+ end
292
+
293
+ # Converts a group of 3 digits (0-999) to words.
294
+ #
295
+ # @param number [Integer] Number between 0 and 999
296
+ # @return [String] The textual representation
297
+ #
298
+ # @private
299
+ def self.convert_group(number)
300
+ ones = %w[zero um dois três quatro cinco seis sete oito nove]
301
+ tens = %w[dez onze doze treze quatorze quinze dezesseis dezessete dezoito dezenove]
302
+ tens_multiples = %w[_ _ vinte trinta quarenta cinquenta sessenta setenta oitenta noventa]
303
+ hundreds = %w[
304
+ _
305
+ cento
306
+ duzentos
307
+ trezentos
308
+ quatrocentos
309
+ quinhentos
310
+ seiscentos
311
+ setecentos
312
+ oitocentos
313
+ novecentos
314
+ ]
315
+
316
+ return ones[number] if number < 10
317
+
318
+ if number < 20
319
+ return tens[number - 10]
320
+ end
321
+
322
+ if number < 100
323
+ tens_digit = number / 10
324
+ ones_digit = number % 10
325
+ if ones_digit.zero?
326
+ return tens_multiples[tens_digit]
327
+ else
328
+ return "#{tens_multiples[tens_digit]} e #{ones[ones_digit]}"
329
+ end
330
+ end
331
+
332
+ # 100-999
333
+ hundreds_digit = number / 100
334
+ remainder = number % 100
335
+
336
+ if number == 100
337
+ 'cem'
338
+ elsif remainder.zero?
339
+ hundreds[hundreds_digit]
340
+ else
341
+ "#{hundreds[hundreds_digit]} e #{convert_group(remainder)}"
342
+ end
343
+ end
344
+
345
+ private_class_method :number_to_words, :convert_group
346
+ end
347
+ end