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,244 +1,504 @@
1
- require 'date'
2
-
3
- module BrazilianUtils
4
- # Brazilian months enumeration
5
- module Months
6
- NAMES = {
7
- 1 => 'janeiro',
8
- 2 => 'fevereiro',
9
- 3 => 'março',
10
- 4 => 'abril',
11
- 5 => 'maio',
12
- 6 => 'junho',
13
- 7 => 'julho',
14
- 8 => 'agosto',
15
- 9 => 'setembro',
16
- 10 => 'outubro',
17
- 11 => 'novembro',
18
- 12 => 'dezembro'
19
- }.freeze
20
-
21
- def self.name(month_number)
22
- NAMES[month_number]
23
- end
24
- end
25
-
26
- module DateUtils
27
- DATE_REGEX = /^\d{2}\/\d{2}\/\d{4}$/.freeze
28
-
29
- # Brazilian national holidays (fixed dates)
30
- NATIONAL_HOLIDAYS = {
31
- [1, 1] => 'Ano Novo',
32
- [4, 21] => 'Tiradentes',
33
- [5, 1] => 'Dia do Trabalho',
34
- [9, 7] => 'Independência do Brasil',
35
- [10, 12] => 'Nossa Senhora Aparecida',
36
- [11, 2] => 'Finados',
37
- [11, 15] => 'Proclamação da República',
38
- [12, 25] => 'Natal'
39
- }.freeze
40
-
41
- # State-specific holidays (fixed dates)
42
- STATE_HOLIDAYS = {
43
- 'AC' => { [1, 23] => 'Dia do Evangélico', [6, 15] => 'Aniversário do Acre', [9, 5] => 'Dia da Amazônia', [11, 17] => 'Assinatura do Tratado de Petrópolis' },
44
- 'AL' => { [6, 24] => 'São João', [6, 29] => 'São Pedro', [9, 16] => 'Emancipação Política', [11, 20] => 'Morte de Zumbi dos Palmares' },
45
- 'AM' => { [9, 5] => 'Elevação do Amazonas à categoria de província' },
46
- 'AP' => { [3, 19] => 'Dia de São José', [9, 13] => 'Criação do Território Federal' },
47
- 'BA' => { [7, 2] => 'Independência da Bahia' },
48
- 'CE' => { [3, 19] => 'São José', [3, 25] => 'Data Magna do Ceará' },
49
- 'DF' => { [4, 21] => 'Fundação de Brasília', [11, 30] => 'Dia do Evangélico' },
50
- 'ES' => { [4, 21] => 'Nossa Senhora da Penha' },
51
- 'GO' => { [10, 24] => 'Pedra fundamental de Goiânia' },
52
- 'MA' => { [7, 28] => 'Adesão do Maranhão à independência do Brasil' },
53
- 'MG' => { [4, 21] => 'Data Magna de Minas Gerais' },
54
- 'MS' => { [10, 11] => 'Criação do estado' },
55
- 'MT' => { [11, 20] => 'Dia da Consciência Negra' },
56
- 'PA' => { [8, 15] => 'Adesão do Grão-Pará à independência do Brasil' },
57
- 'PB' => { [7, 26] => 'Homenagem à memória do ex-presidente João Pessoa', [8, 5] => 'Fundação do Estado em 1585' },
58
- 'PE' => { [3, 6] => 'Revolução Pernambucana de 1817', [6, 24] => 'São João' },
59
- 'PI' => { [10, 19] => 'Dia do Piauí' },
60
- 'PR' => { [12, 19] => 'Emancipação política do Paraná' },
61
- 'RJ' => { [4, 23] => 'Dia de São Jorge', [11, 20] => 'Dia da Consciência Negra' },
62
- 'RN' => { [6, 29] => 'Dia de São Pedro', [10, 3] => 'Mártires de Cunhaú e Uruaçu' },
63
- 'RO' => { [1, 4] => 'Criação do estado', [6, 18] => 'Dia do Evangélico' },
64
- 'RR' => { [10, 5] => 'Criação de Roraima' },
65
- 'RS' => { [9, 20] => 'Revolução Farroupilha' },
66
- 'SC' => { [8, 11] => 'Criação da capitania, separando-se de SP' },
67
- 'SE' => { [7, 8] => 'Autonomia política de Sergipe' },
68
- 'SP' => { [7, 9] => 'Revolução Constitucionalista de 1932' },
69
- 'TO' => { [10, 5] => 'Criação de Tocantins' }
70
- }.freeze
71
-
72
- VALID_UFS = %w[
73
- AC AL AP AM BA CE DF ES GO MA MT MS MG PA PB PR PE PI RJ RN RS RO RR SC SP SE TO
74
- ].freeze
75
-
76
- # Checks if the given date is a national or state holiday in Brazil.
77
- #
78
- # This function takes a date as a Date or DateTime object and an optional UF (Unidade Federativa),
79
- # returning a boolean value indicating whether the date is a holiday or nil if the date or
80
- # UF are invalid.
81
- #
82
- # The method does not handle municipal holidays.
83
- #
84
- # @param target_date [Date, DateTime, Time] The date to be checked.
85
- # @param uf [String, nil] The state abbreviation (UF) to check for state holidays.
86
- # If not provided, only national holidays will be considered.
87
- #
88
- # @return [Boolean, nil] Returns true if the date is a holiday, false if it is not,
89
- # or nil if the date or UF are invalid.
90
- #
91
- # @note This implementation includes fixed national and state holidays.
92
- # Movable holidays (like Carnival, Easter) are not included in this basic implementation.
93
- #
94
- # @example
95
- # is_holiday(Date.new(2024, 1, 1)) #=> true (New Year)
96
- # is_holiday(Date.new(2024, 1, 2)) #=> false
97
- # is_holiday(Date.new(2024, 7, 9), 'SP') #=> true (SP state holiday)
98
- # is_holiday(Date.new(2024, 12, 25), 'RJ') #=> true (Christmas)
99
- def self.is_holiday(target_date, uf = nil)
100
- return nil unless target_date.is_a?(Date) || target_date.is_a?(DateTime) || target_date.is_a?(Time)
101
-
102
- # Convert to Date if needed
103
- date = target_date.is_a?(Date) ? target_date : target_date.to_date
104
-
105
- # Validate UF if provided
106
- if uf && !VALID_UFS.include?(uf.to_s.upcase)
107
- return nil
108
- end
109
-
110
- month_day = [date.month, date.day]
111
-
112
- # Check national holidays
113
- return true if NATIONAL_HOLIDAYS.key?(month_day)
114
-
115
- # Check state holidays if UF is provided
116
- if uf
117
- state_uf = uf.to_s.upcase
118
- state_holidays = STATE_HOLIDAYS[state_uf]
119
- return true if state_holidays && state_holidays.key?(month_day)
120
- end
121
-
122
- false
123
- end
124
-
125
- # Converts a given date in Brazilian format (dd/mm/yyyy) to its textual representation.
126
- #
127
- # This function takes a date as a string in the format dd/mm/yyyy and converts it
128
- # to a string with the date written out in Brazilian Portuguese, including the full
129
- # month name and the year.
130
- #
131
- # @param date [String] The date to be converted into text. Expected format: dd/mm/yyyy.
132
- #
133
- # @return [String, nil] A string with the date written out in Brazilian Portuguese,
134
- # or nil if the date is invalid.
135
- #
136
- # @example
137
- # convert_date_to_text("01/01/2024") #=> "Primeiro de janeiro de dois mil e vinte e quatro"
138
- # convert_date_to_text("15/03/2024") #=> "Quinze de março de dois mil e vinte e quatro"
139
- # convert_date_to_text("invalid") #=> nil
140
- def self.convert_date_to_text(date)
141
- return nil unless DATE_REGEX.match?(date)
142
-
143
- begin
144
- dt = Date.strptime(date, '%d/%m/%Y')
145
- rescue ArgumentError
146
- return nil
147
- end
148
-
149
- day = dt.day
150
- month = dt.month
151
- year = dt.year
152
-
153
- # Convert day to text (special case for 1st)
154
- day_str = if day == 1
155
- 'Primeiro'
156
- else
157
- # Reuse number_to_words from CurrencyUtils or implement inline
158
- number_to_words(day).capitalize
159
- end
160
-
161
- month_name = Months.name(month)
162
- year_str = number_to_words(year)
163
-
164
- "#{day_str} de #{month_name} de #{year_str}"
165
- end
166
-
167
- # Converts a number to its textual representation in Brazilian Portuguese.
168
- # This is a simplified version focused on dates (days 1-31, years).
169
- #
170
- # @param number [Integer] The number to convert
171
- # @return [String] The textual representation
172
- #
173
- # @private
174
- def self.number_to_words(number)
175
- return 'zero' if number.zero?
176
-
177
- ones = %w[zero um dois três quatro cinco seis sete oito nove]
178
- tens = %w[dez onze doze treze quatorze quinze dezesseis dezessete dezoito dezenove]
179
- tens_multiples = %w[_ _ vinte trinta quarenta cinquenta sessenta setenta oitenta noventa]
180
- hundreds = %w[
181
- _
182
- cento
183
- duzentos
184
- trezentos
185
- quatrocentos
186
- quinhentos
187
- seiscentos
188
- setecentos
189
- oitocentos
190
- novecentos
191
- ]
192
-
193
- if number < 10
194
- return ones[number]
195
- elsif number < 20
196
- return tens[number - 10]
197
- elsif number < 100
198
- tens_digit = number / 10
199
- ones_digit = number % 10
200
- if ones_digit.zero?
201
- return tens_multiples[tens_digit]
202
- else
203
- return "#{tens_multiples[tens_digit]} e #{ones[ones_digit]}"
204
- end
205
- elsif number == 100
206
- return 'cem'
207
- elsif number < 1000
208
- hundreds_digit = number / 100
209
- remainder = number % 100
210
- if remainder.zero?
211
- return hundreds[hundreds_digit]
212
- else
213
- return "#{hundreds[hundreds_digit]} e #{number_to_words(remainder)}"
214
- end
215
- elsif number < 1_000_000
216
- # For years like 2024
217
- thousands = number / 1000
218
- remainder = number % 1000
219
-
220
- result = []
221
-
222
- if thousands == 1
223
- result << 'mil'
224
- else
225
- result << "#{number_to_words(thousands)} mil"
226
- end
227
-
228
- if remainder > 0
229
- if remainder < 100
230
- result << "e #{number_to_words(remainder)}"
231
- else
232
- result << number_to_words(remainder)
233
- end
234
- end
235
-
236
- result.join(' ')
237
- else
238
- number.to_s
239
- end
240
- end
241
-
242
- private_class_method :number_to_words
243
- end
244
- end
1
+ require 'date'
2
+
3
+ module BrazilianUtils
4
+ # Brazilian months enumeration
5
+ module Months
6
+ NAMES = {
7
+ 1 => 'janeiro',
8
+ 2 => 'fevereiro',
9
+ 3 => 'março',
10
+ 4 => 'abril',
11
+ 5 => 'maio',
12
+ 6 => 'junho',
13
+ 7 => 'julho',
14
+ 8 => 'agosto',
15
+ 9 => 'setembro',
16
+ 10 => 'outubro',
17
+ 11 => 'novembro',
18
+ 12 => 'dezembro'
19
+ }.freeze
20
+
21
+ def self.name(month_number)
22
+ NAMES[month_number]
23
+ end
24
+ end
25
+
26
+ module DateUtils
27
+ DATE_REGEX = /^\d{2}\/\d{2}\/\d{4}$/.freeze
28
+ ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/.freeze
29
+
30
+ MIN_YEAR = 1900
31
+ MAX_YEAR = 2099
32
+
33
+ # Brazilian national holidays (fixed dates)
34
+ NATIONAL_HOLIDAYS = {
35
+ [1, 1] => 'Ano Novo',
36
+ [4, 21] => 'Tiradentes',
37
+ [5, 1] => 'Dia do Trabalho',
38
+ [9, 7] => 'Independência do Brasil',
39
+ [10, 12] => 'Nossa Senhora Aparecida',
40
+ [11, 2] => 'Finados',
41
+ [11, 15] => 'Proclamação da República',
42
+ [12, 25] => 'Natal'
43
+ }.freeze
44
+
45
+ # Lei 14.759/2023 made November 20th (Dia Nacional de Zumbi e da
46
+ # Consciência Negra) a national holiday starting in 2024; before that it
47
+ # was only a holiday in the states/cities that already had their own law
48
+ # for it (some of which are still listed in STATE_HOLIDAYS below).
49
+ NATIONAL_CONSCIENCIA_NEGRA_MONTH_DAY = [11, 20].freeze
50
+ NATIONAL_CONSCIENCIA_NEGRA_EFFECTIVE_YEAR = 2024
51
+ NATIONAL_CONSCIENCIA_NEGRA_NAME = 'Dia Nacional de Zumbi e da Consciência Negra'
52
+
53
+ # State-specific holidays (fixed dates)
54
+ STATE_HOLIDAYS = {
55
+ 'AC' => { [1, 23] => 'Dia do Evangélico', [6, 15] => 'Aniversário do Acre', [9, 5] => 'Dia da Amazônia', [11, 17] => 'Assinatura do Tratado de Petrópolis' },
56
+ 'AL' => { [6, 24] => 'São João', [6, 29] => 'São Pedro', [9, 16] => 'Emancipação Política', [11, 20] => 'Morte de Zumbi dos Palmares' },
57
+ 'AM' => { [9, 5] => 'Elevação do Amazonas à categoria de província' },
58
+ 'AP' => { [3, 19] => 'Dia de São José', [9, 13] => 'Criação do Território Federal' },
59
+ 'BA' => { [7, 2] => 'Independência da Bahia' },
60
+ 'CE' => { [3, 19] => 'São José', [3, 25] => 'Data Magna do Ceará' },
61
+ 'DF' => { [4, 21] => 'Fundação de Brasília', [11, 30] => 'Dia do Evangélico' },
62
+ 'ES' => { [4, 21] => 'Nossa Senhora da Penha' },
63
+ 'GO' => { [10, 24] => 'Pedra fundamental de Goiânia' },
64
+ 'MA' => { [7, 28] => 'Adesão do Maranhão à independência do Brasil' },
65
+ 'MG' => { [4, 21] => 'Data Magna de Minas Gerais' },
66
+ 'MS' => { [10, 11] => 'Criação do estado' },
67
+ 'MT' => { [11, 20] => 'Dia da Consciência Negra' },
68
+ 'PA' => { [8, 15] => 'Adesão do Grão-Pará à independência do Brasil' },
69
+ 'PB' => { [7, 26] => 'Homenagem à memória do ex-presidente João Pessoa', [8, 5] => 'Fundação do Estado em 1585' },
70
+ 'PE' => { [3, 6] => 'Revolução Pernambucana de 1817', [6, 24] => 'São João' },
71
+ 'PI' => { [10, 19] => 'Dia do Piauí' },
72
+ 'PR' => { [12, 19] => 'Emancipação política do Paraná' },
73
+ 'RJ' => { [4, 23] => 'Dia de São Jorge', [11, 20] => 'Dia da Consciência Negra' },
74
+ 'RN' => { [6, 29] => 'Dia de São Pedro', [10, 3] => 'Mártires de Cunhaú e Uruaçu' },
75
+ 'RO' => { [1, 4] => 'Criação do estado', [6, 18] => 'Dia do Evangélico' },
76
+ 'RR' => { [10, 5] => 'Criação de Roraima' },
77
+ 'RS' => { [9, 20] => 'Revolução Farroupilha' },
78
+ 'SC' => { [8, 11] => 'Criação da capitania, separando-se de SP' },
79
+ 'SE' => { [7, 8] => 'Autonomia política de Sergipe' },
80
+ 'SP' => { [7, 9] => 'Revolução Constitucionalista de 1932' },
81
+ 'TO' => { [10, 5] => 'Criação de Tocantins' }
82
+ }.freeze
83
+
84
+ VALID_UFS = %w[
85
+ AC AL AP AM BA CE DF ES GO MA MT MS MG PA PB PR PE PI RJ RN RS RO RR SC SP SE TO
86
+ ].freeze
87
+
88
+ # Checks if the given date is a national or state holiday in Brazil.
89
+ #
90
+ # Accepts either the legacy positional form `is_holiday(date, uf)` or a
91
+ # single options Hash (as the contract's `IsHolidayParams`), e.g.
92
+ # `is_holiday(date: Date.new(2024, 7, 9), state: 'SP')`.
93
+ #
94
+ # @param target_date [Date, DateTime, Time, Hash] The date to check, or
95
+ # an options Hash with `:date` and an optional `:state`/`:uf`.
96
+ # @param uf [String, nil] The state abbreviation (UF) to check for state
97
+ # holidays, when `target_date` is not itself a Hash. An unknown UF is
98
+ # simply ignored (national holidays are still checked).
99
+ #
100
+ # @return [Boolean] true if the date is a holiday, false otherwise
101
+ # (including when the date is missing or invalid).
102
+ #
103
+ # @note This implementation includes fixed national and state holidays,
104
+ # plus the moveable Sexta-feira Santa (Good Friday).
105
+ #
106
+ # @example
107
+ # is_holiday(Date.new(2024, 1, 1)) #=> true (New Year)
108
+ # is_holiday(Date.new(2024, 1, 2)) #=> false
109
+ # is_holiday(Date.new(2024, 7, 9), 'SP') #=> true (SP state holiday)
110
+ # is_holiday(date: Date.new(2024, 12, 25)) #=> true (Christmas)
111
+ def self.is_holiday(target_date = nil, uf = nil)
112
+ if target_date.is_a?(Hash)
113
+ opts = target_date
114
+ target_date = opts[:date] || opts['date']
115
+ uf = opts[:state] || opts[:uf] || opts['state'] || opts['uf']
116
+ end
117
+
118
+ return false unless target_date.is_a?(Date) || target_date.is_a?(DateTime) || target_date.is_a?(Time)
119
+
120
+ date = target_date.is_a?(Date) ? target_date : target_date.to_date
121
+ return false unless date.year.between?(MIN_YEAR, MAX_YEAR)
122
+
123
+ month_day = [date.month, date.day]
124
+
125
+ return true if NATIONAL_HOLIDAYS.key?(month_day)
126
+
127
+ if month_day == NATIONAL_CONSCIENCIA_NEGRA_MONTH_DAY && date.year >= NATIONAL_CONSCIENCIA_NEGRA_EFFECTIVE_YEAR
128
+ return true
129
+ end
130
+
131
+ return true if date == good_friday(date.year)
132
+
133
+ if uf
134
+ state_uf = uf.to_s.upcase
135
+ state_holidays = STATE_HOLIDAYS[state_uf]
136
+ return true if state_holidays && state_holidays.key?(month_day)
137
+ end
138
+
139
+ false
140
+ end
141
+
142
+ # Returns the date of Easter Sunday (Domingo de Páscoa) for a given
143
+ # Gregorian year, via the anonymous Gregorian computus algorithm
144
+ # (Meeus/Jones/Butcher).
145
+ #
146
+ # @param year [Integer]
147
+ # @return [Date]
148
+ #
149
+ # @private
150
+ def self.easter_sunday(year)
151
+ a = year % 19
152
+ b = year / 100
153
+ c = year % 100
154
+ d = b / 4
155
+ e = b % 4
156
+ f = (b + 8) / 25
157
+ g = (b - f + 1) / 3
158
+ h = (19 * a + b - d - g + 15) % 30
159
+ i = c / 4
160
+ k = c % 4
161
+ l = (32 + 2 * e + 2 * i - h - k) % 7
162
+ m = (a + 11 * h + 22 * l) / 451
163
+ month = (h + l - 7 * m + 114) / 31
164
+ day = ((h + l - 7 * m + 114) % 31) + 1
165
+ Date.new(year, month, day)
166
+ end
167
+
168
+ private_class_method :easter_sunday
169
+
170
+ # @private
171
+ def self.good_friday(year)
172
+ easter_sunday(year) - 2
173
+ end
174
+
175
+ private_class_method :good_friday
176
+
177
+ # @private
178
+ def self.carnaval_monday(year)
179
+ easter_sunday(year) - 48
180
+ end
181
+
182
+ private_class_method :carnaval_monday
183
+
184
+ # @private
185
+ def self.carnaval_tuesday(year)
186
+ easter_sunday(year) - 47
187
+ end
188
+
189
+ private_class_method :carnaval_tuesday
190
+
191
+ # @private
192
+ def self.corpus_christi(year)
193
+ easter_sunday(year) + 60
194
+ end
195
+
196
+ private_class_method :corpus_christi
197
+
198
+ # Returns the Brazilian holidays of a given year, sorted by date.
199
+ #
200
+ # Each holiday is a Hash with `:name`, `:date` (a `Date`) and `:type`
201
+ # (`:national`, `:state`, `:optional` or `:religious`).
202
+ #
203
+ # @param year [Integer] A year between 1900 and 2099.
204
+ # @return [Array<Hash>] The holidays, sorted by date; an empty array when
205
+ # `year` is out of range.
206
+ #
207
+ # @example
208
+ # get_holidays(2024).first
209
+ # #=> { name: "Ano Novo", date: #<Date: 2024-01-01>, type: :national }
210
+ def self.get_holidays(year)
211
+ return [] unless year.is_a?(Integer) && year.between?(MIN_YEAR, MAX_YEAR)
212
+
213
+ holidays = []
214
+
215
+ NATIONAL_HOLIDAYS.each do |(month, day), name|
216
+ holidays << { name: name, date: Date.new(year, month, day), type: :national }
217
+ end
218
+
219
+ if year >= NATIONAL_CONSCIENCIA_NEGRA_EFFECTIVE_YEAR
220
+ holidays << {
221
+ name: NATIONAL_CONSCIENCIA_NEGRA_NAME,
222
+ date: Date.new(year, *NATIONAL_CONSCIENCIA_NEGRA_MONTH_DAY),
223
+ type: :national
224
+ }
225
+ end
226
+
227
+ holidays << { name: 'Sexta-feira Santa', date: good_friday(year), type: :religious }
228
+ holidays << { name: 'Carnaval', date: carnaval_monday(year), type: :optional }
229
+ holidays << { name: 'Carnaval', date: carnaval_tuesday(year), type: :optional }
230
+ holidays << { name: 'Corpus Christi', date: corpus_christi(year), type: :optional }
231
+
232
+ holidays.sort_by { |h| h[:date] }
233
+ end
234
+
235
+ # @private
236
+ def self.coerce_date(value)
237
+ case value
238
+ when Date, DateTime, Time
239
+ value.is_a?(Date) ? value : value.to_date
240
+ else
241
+ nil
242
+ end
243
+ end
244
+
245
+ private_class_method :coerce_date
246
+
247
+ # Checks whether a date is a Brazilian business day (dia útil): not a
248
+ # Saturday, a Sunday, nor a holiday from {get_holidays}.
249
+ #
250
+ # @param value [Date, DateTime, Time] The date to check.
251
+ # @param options [Hash] `:optional` (default true) also counts Carnaval
252
+ # and Corpus Christi; `:state`/`:uf` also counts that state's holidays.
253
+ # @return [Boolean] false for an invalid date or one outside 1900..2099.
254
+ def self.is_business_day(value, options = {})
255
+ date = coerce_date(value)
256
+ return false unless date
257
+ return false unless date.year.between?(MIN_YEAR, MAX_YEAR)
258
+ return false if [0, 6].include?(date.wday) # Sunday = 0, Saturday = 6
259
+
260
+ count_optional = options[:optional].nil? && options['optional'].nil? ? true : (options[:optional] || options['optional'])
261
+ state = options[:state] || options[:uf] || options['state'] || options['uf']
262
+
263
+ holidays = get_holidays(date.year)
264
+ holidays.concat(get_holidays(date.year - 1), get_holidays(date.year + 1)) if date.month == 1 || date.month == 12
265
+
266
+ holidays.each do |holiday|
267
+ next if holiday[:date] != date
268
+ next if holiday[:type] == :optional && !count_optional
269
+
270
+ return false
271
+ end
272
+
273
+ if state
274
+ state_uf = state.to_s.upcase
275
+ state_holidays = STATE_HOLIDAYS[state_uf]
276
+ return false if state_holidays && state_holidays.key?([date.month, date.day])
277
+ end
278
+
279
+ true
280
+ end
281
+
282
+ class << self
283
+ alias business_day? is_business_day
284
+ end
285
+
286
+ # Adds (or, with a negative amount, subtracts) a number of Brazilian
287
+ # business days to a date, skipping weekends and holidays.
288
+ #
289
+ # @param date [Date, DateTime, Time] The starting date.
290
+ # @param amount [Integer] The number of business days to add.
291
+ # @param options [Hash] Same as {is_business_day}.
292
+ # @return [Date, DateTime, Time, nil] A new date/time of the same class
293
+ # as the input (time of day preserved), or nil for an invalid date, a
294
+ # non-integer amount, or a result outside 1900..2099.
295
+ def self.add_business_days(date, amount, options = {})
296
+ return nil unless date.is_a?(Date) || date.is_a?(DateTime) || date.is_a?(Time)
297
+ return nil unless amount.is_a?(Integer)
298
+
299
+ base_date = coerce_date(date)
300
+ return nil unless base_date
301
+
302
+ remaining = amount.abs
303
+ step = amount.negative? ? -1 : 1
304
+ current = base_date
305
+
306
+ while remaining.positive?
307
+ current += step
308
+ remaining -= 1 if is_business_day(current, options)
309
+ end
310
+
311
+ return nil unless current.year.between?(MIN_YEAR, MAX_YEAR)
312
+
313
+ diff_days = (current - base_date).to_i
314
+ date + diff_days
315
+ end
316
+
317
+ # Subtracts a number of Brazilian business days from a date.
318
+ #
319
+ # @param date [Date, DateTime, Time] The starting date.
320
+ # @param amount [Integer] The number of business days to subtract.
321
+ # @param options [Hash] Same as {is_business_day}.
322
+ # @return [Date, DateTime, Time, nil] See {add_business_days}.
323
+ def self.sub_business_days(date, amount, options = {})
324
+ return nil unless amount.is_a?(Integer)
325
+
326
+ add_business_days(date, -amount, options)
327
+ end
328
+
329
+ # Counts the Brazilian business days between two dates: `earlier_date`
330
+ # (when it is itself a business day) and every business day strictly
331
+ # between the two; `later_date` is never counted.
332
+ #
333
+ # @param later_date [Date, DateTime, Time]
334
+ # @param earlier_date [Date, DateTime, Time]
335
+ # @param options [Hash] Same as {is_business_day}.
336
+ # @return [Integer, nil] Negative when `later_date` precedes
337
+ # `earlier_date`; 0 on the same calendar day; nil when either date is
338
+ # invalid or outside 1900..2099.
339
+ def self.difference_in_business_days(later_date, earlier_date, options = {})
340
+ later = coerce_date(later_date)
341
+ earlier = coerce_date(earlier_date)
342
+ return nil unless later && earlier
343
+ return nil unless later.year.between?(MIN_YEAR, MAX_YEAR) && earlier.year.between?(MIN_YEAR, MAX_YEAR)
344
+
345
+ return 0 if later == earlier
346
+
347
+ if later > earlier
348
+ count = 0
349
+ d = earlier
350
+ while d < later
351
+ count += 1 if is_business_day(d, options)
352
+ d += 1
353
+ end
354
+ count
355
+ else
356
+ -difference_in_business_days(earlier, later, options)
357
+ end
358
+ end
359
+
360
+ # Converts a given date to its textual representation in Brazilian
361
+ # Portuguese ("por extenso"), e.g. `"primeiro de janeiro de dois mil e
362
+ # vinte e quatro"`.
363
+ #
364
+ # @param date [String, Date, DateTime, Time] The date to convert. A
365
+ # string may be `dd/mm/yyyy` or ISO `yyyy-mm-dd`.
366
+ #
367
+ # @return [String] The date written out in Brazilian Portuguese (all
368
+ # lower case), or an empty string when the date is invalid.
369
+ #
370
+ # @example
371
+ # convert_date_to_text("01/01/2024") #=> "primeiro de janeiro de dois mil e vinte e quatro"
372
+ # convert_date_to_text("15/03/2024") #=> "quinze de março de dois mil e vinte e quatro"
373
+ # convert_date_to_text("invalid") #=> ""
374
+ def self.convert_date_to_text(date)
375
+ dt =
376
+ case date
377
+ when Date, DateTime, Time
378
+ date.is_a?(Date) ? date : date.to_date
379
+ when String
380
+ parse_text_date(date)
381
+ end
382
+
383
+ return '' unless dt
384
+
385
+ day = dt.day
386
+ month = dt.month
387
+ year = dt.year
388
+
389
+ # Convert day to text (special case for 1st)
390
+ day_str = day == 1 ? 'primeiro' : number_to_words(day)
391
+
392
+ month_name = Months.name(month)
393
+ year_str = number_to_words(year)
394
+
395
+ "#{day_str} de #{month_name} de #{year_str}"
396
+ end
397
+
398
+ # Parses a `dd/mm/yyyy` or ISO `yyyy-mm-dd` date string.
399
+ #
400
+ # @return [Date, nil]
401
+ #
402
+ # @private
403
+ def self.parse_text_date(date)
404
+ return nil unless date.is_a?(String)
405
+
406
+ if DATE_REGEX.match?(date)
407
+ begin
408
+ return Date.strptime(date, '%d/%m/%Y')
409
+ rescue ArgumentError
410
+ return nil
411
+ end
412
+ end
413
+
414
+ if ISO_DATE_REGEX.match?(date)
415
+ begin
416
+ return Date.strptime(date, '%Y-%m-%d')
417
+ rescue ArgumentError
418
+ return nil
419
+ end
420
+ end
421
+
422
+ nil
423
+ end
424
+
425
+ private_class_method :parse_text_date
426
+
427
+ # Converts a number to its textual representation in Brazilian Portuguese.
428
+ # This is a simplified version focused on dates (days 1-31, years).
429
+ #
430
+ # @param number [Integer] The number to convert
431
+ # @return [String] The textual representation
432
+ #
433
+ # @private
434
+ def self.number_to_words(number)
435
+ return 'zero' if number.zero?
436
+
437
+ ones = %w[zero um dois três quatro cinco seis sete oito nove]
438
+ tens = %w[dez onze doze treze quatorze quinze dezesseis dezessete dezoito dezenove]
439
+ tens_multiples = %w[_ _ vinte trinta quarenta cinquenta sessenta setenta oitenta noventa]
440
+ hundreds = %w[
441
+ _
442
+ cento
443
+ duzentos
444
+ trezentos
445
+ quatrocentos
446
+ quinhentos
447
+ seiscentos
448
+ setecentos
449
+ oitocentos
450
+ novecentos
451
+ ]
452
+
453
+ if number < 10
454
+ return ones[number]
455
+ elsif number < 20
456
+ return tens[number - 10]
457
+ elsif number < 100
458
+ tens_digit = number / 10
459
+ ones_digit = number % 10
460
+ if ones_digit.zero?
461
+ return tens_multiples[tens_digit]
462
+ else
463
+ return "#{tens_multiples[tens_digit]} e #{ones[ones_digit]}"
464
+ end
465
+ elsif number == 100
466
+ return 'cem'
467
+ elsif number < 1000
468
+ hundreds_digit = number / 100
469
+ remainder = number % 100
470
+ if remainder.zero?
471
+ return hundreds[hundreds_digit]
472
+ else
473
+ return "#{hundreds[hundreds_digit]} e #{number_to_words(remainder)}"
474
+ end
475
+ elsif number < 1_000_000
476
+ # For years like 2024
477
+ thousands = number / 1000
478
+ remainder = number % 1000
479
+
480
+ result = []
481
+
482
+ if thousands == 1
483
+ result << 'mil'
484
+ else
485
+ result << "#{number_to_words(thousands)} mil"
486
+ end
487
+
488
+ if remainder > 0
489
+ if remainder < 100
490
+ result << "e #{number_to_words(remainder)}"
491
+ else
492
+ result << number_to_words(remainder)
493
+ end
494
+ end
495
+
496
+ result.join(' ')
497
+ else
498
+ number.to_s
499
+ end
500
+ end
501
+
502
+ private_class_method :number_to_words
503
+ end
504
+ end