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
@@ -97,7 +97,8 @@ module BrazilianUtils
97
97
  return false unless pis.is_a?(String)
98
98
  return false unless pis.length == 11
99
99
  return false unless pis.match?(/^\d+$/)
100
-
100
+ return false if pis.chars.uniq.length == 1 # e.g. "00000000000", "99999999999"
101
+
101
102
  pis[-1] == checksum(pis[0..9]).to_s
102
103
  end
103
104
 
@@ -147,5 +148,19 @@ module BrazilianUtils
147
148
  end
148
149
 
149
150
  private_class_method :checksum
151
+
152
+ # Removes PIS formatting and keeps only digits, capped to 11 digits.
153
+ #
154
+ # @param value [String, Integer] A PIS, with or without formatting.
155
+ # @return [String] The digits-only value, capped to 11 characters.
156
+ #
157
+ # @example
158
+ # parse("123.45678.90-1") #=> "12345678901"
159
+ # parse("12345678901123") #=> "12345678901"
160
+ def self.parse(value)
161
+ return '' unless value.is_a?(String) || value.is_a?(Integer)
162
+
163
+ value.to_s.gsub(/\D/, '')[0, 11]
164
+ end
150
165
  end
151
166
  end
@@ -0,0 +1,70 @@
1
+ require_relative 'cpf-utils'
2
+ require_relative 'cnpj-utils'
3
+ require_relative 'phone-utils'
4
+
5
+ module BrazilianUtils
6
+ # Utilities for identifying and validating a Pix key (DICT key formats):
7
+ # a CPF, a CNPJ, an email, a Brazilian mobile phone or a random EVP key.
8
+ module PixKeyUtils
9
+ UUID_REGEX = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i.freeze
10
+ EMAIL_REGEX = /\A[^\s@]+@[^\s@]+\.[^\s@]+\z/.freeze
11
+ MAX_EMAIL_LENGTH = 77
12
+
13
+ # Identifies a Pix key and normalizes it to the canonical form the
14
+ # DICT expects inside a BR Code.
15
+ #
16
+ # @param value [String]
17
+ # @return [Hash, nil] `{ type:, value: }`, or nil when `value` is not a
18
+ # valid Pix key.
19
+ def self.get_info(value)
20
+ return nil unless value.is_a?(String)
21
+
22
+ str = value.strip
23
+ return nil if str.empty?
24
+
25
+ return { type: 'evp', value: str.downcase } if UUID_REGEX.match?(str)
26
+
27
+ if str.include?('@')
28
+ return nil if str.length > MAX_EMAIL_LENGTH
29
+ return nil unless EMAIL_REGEX.match?(str)
30
+
31
+ return { type: 'email', value: str.downcase }
32
+ end
33
+
34
+ if str.start_with?('+') || str.include?('(')
35
+ digits = PhoneUtils.parse(str)
36
+ return nil unless PhoneUtils.is_valid_mobile(digits)
37
+
38
+ return { type: 'phone', value: "+55#{digits}" }
39
+ end
40
+
41
+ digits = str.gsub(/\D/, '')
42
+ case digits.length
43
+ when 11
44
+ CPFUtils.valid?(digits) ? { type: 'cpf', value: digits } : nil
45
+ when 14
46
+ CNPJUtils.valid?(digits) ? { type: 'cnpj', value: digits } : nil
47
+ end
48
+ end
49
+
50
+ # Checks whether a value is a valid Pix key.
51
+ #
52
+ # @param value [String]
53
+ # @param options [Hash] `:types` restricts the accepted key types
54
+ # (`%w[cpf cnpj email phone evp]`); an empty list rejects everything.
55
+ # @return [Boolean]
56
+ def self.is_valid(value, options = {})
57
+ info = get_info(value)
58
+ return false unless info
59
+
60
+ types = options[:types] || options['types']
61
+ return true if types.nil?
62
+
63
+ types.map(&:to_s).include?(info[:type])
64
+ end
65
+
66
+ class << self
67
+ alias valid? is_valid
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,253 @@
1
+ require_relative 'text-utils'
2
+ require_relative 'pix-key-utils'
3
+
4
+ module BrazilianUtils
5
+ # Utilities for the Pix "BR Code" payload: the EMV-derived TLV (tag-
6
+ # length-value) QR code format defined by the Banco Central's Manual de
7
+ # Padrões para Iniciação do Pix.
8
+ module PixPayloadUtils
9
+ PIX_GUI = 'br.gov.bcb.pix'
10
+
11
+ # @private
12
+ def self.crc16(str)
13
+ crc = 0xFFFF
14
+ str.each_byte do |byte|
15
+ crc ^= (byte << 8)
16
+ 8.times do
17
+ crc = (crc & 0x8000).zero? ? (crc << 1) & 0xFFFF : ((crc << 1) ^ 0x1021) & 0xFFFF
18
+ end
19
+ end
20
+ crc
21
+ end
22
+
23
+ private_class_method :crc16
24
+
25
+ # Parses a flat TLV string into an ordered list of `[id, value]` pairs.
26
+ #
27
+ # @return [Array<Array(String, String)>, nil] nil when malformed.
28
+ #
29
+ # @private
30
+ def self.parse_tlv(str)
31
+ fields = []
32
+ pos = 0
33
+
34
+ while pos < str.length
35
+ return nil if pos + 4 > str.length
36
+
37
+ id = str[pos, 2]
38
+ len_str = str[pos + 2, 2]
39
+ return nil unless id.match?(/\A\d{2}\z/) && len_str.match?(/\A\d{2}\z/)
40
+
41
+ len = len_str.to_i
42
+ value = str[pos + 4, len]
43
+ return nil if value.nil? || value.length != len
44
+
45
+ fields << [id, value]
46
+ pos += 4 + len
47
+ end
48
+
49
+ fields
50
+ end
51
+
52
+ private_class_method :parse_tlv
53
+
54
+ # @private
55
+ def self.find_pix_merchant_account_info(fields)
56
+ fields.each do |id, value|
57
+ next unless ('26'..'51').cover?(id)
58
+
59
+ nested = parse_tlv(value)
60
+ next unless nested
61
+
62
+ gui = nested.find { |nid, _| nid == '00' }
63
+ next unless gui && gui[1].downcase == PIX_GUI
64
+
65
+ return nested
66
+ end
67
+ nil
68
+ end
69
+
70
+ private_class_method :find_pix_merchant_account_info
71
+
72
+ # @private
73
+ def self.validated_fields(value)
74
+ return nil unless value.is_a?(String) && !value.empty?
75
+ return nil if value.length < 4
76
+
77
+ crc_declared = value[-4..-1]
78
+ return nil unless crc_declared.match?(/\A[0-9A-Fa-f]{4}\z/)
79
+ return nil unless format('%04X', crc16(value[0...-4])) == crc_declared.upcase
80
+
81
+ fields = parse_tlv(value)
82
+ return nil unless fields
83
+ return nil unless fields.last && fields.last[0] == '63' && fields.last[1].length == 4
84
+
85
+ by_id = {}
86
+ fields.each { |id, v| (by_id[id] ||= []) << v }
87
+
88
+ return nil unless by_id['00'] == ['01']
89
+ return nil unless by_id['52']&.first&.match?(/\A\d{4}\z/)
90
+ return nil unless by_id['53']&.first
91
+ return nil unless by_id['58']&.first == 'BR'
92
+ return nil unless by_id['59']&.first&.length&.between?(1, 99)
93
+ return nil unless by_id['60']&.first&.length&.between?(1, 99)
94
+
95
+ poi = by_id['01']&.first
96
+ return nil if poi && !%w[11 12].include?(poi)
97
+
98
+ merchant_account = find_pix_merchant_account_info(fields)
99
+ return nil unless merchant_account
100
+
101
+ key_entry = merchant_account.find { |id, _| id == '01' }
102
+ url_entry = merchant_account.find { |id, _| id == '25' }
103
+ return nil if key_entry.nil? == url_entry.nil?
104
+
105
+ amount = by_id['54']&.first
106
+ return nil if amount && !(amount.to_f > 0)
107
+
108
+ { fields: fields, by_id: by_id, poi: poi, key_entry: key_entry, url_entry: url_entry }
109
+ end
110
+
111
+ private_class_method :validated_fields
112
+
113
+ # Validates a Pix BR Code payload (the key itself is not checked; use
114
+ # `PixKeyUtils.is_valid`).
115
+ #
116
+ # @param value [String]
117
+ # @return [Boolean]
118
+ def self.is_valid(value)
119
+ !validated_fields(value).nil?
120
+ end
121
+
122
+ class << self
123
+ alias valid? is_valid
124
+ end
125
+
126
+ # Parses a Pix BR Code payload into its fields.
127
+ #
128
+ # @param value [String]
129
+ # @return [Hash, nil] nil for anything {is_valid} rejects.
130
+ def self.get_info(value)
131
+ parsed = validated_fields(value)
132
+ return nil unless parsed
133
+
134
+ by_id = parsed[:by_id]
135
+ has_psp_location = !parsed[:url_entry].nil?
136
+ point_of_initiation = (parsed[:poi] == '12' || has_psp_location) ? 'dynamic' : 'static'
137
+
138
+ info = {
139
+ merchantName: by_id['59'].first,
140
+ merchantCity: by_id['60'].first,
141
+ pointOfInitiation: point_of_initiation
142
+ }
143
+
144
+ if has_psp_location
145
+ info[:url] = parsed[:url_entry][1]
146
+ else
147
+ info[:key] = parsed[:key_entry][1]
148
+
149
+ unless has_psp_location
150
+ amount = by_id['54']&.first
151
+ info[:amount] = amount.to_f if amount
152
+
153
+ additional = by_id['62']&.first
154
+ if additional
155
+ nested = parse_tlv(additional)
156
+ txid_entry = nested&.find { |id, _| id == '05' }
157
+ info[:txid] = txid_entry[1] if txid_entry && txid_entry[1] != '***'
158
+ end
159
+ end
160
+ end
161
+
162
+ info
163
+ end
164
+
165
+ # @private
166
+ def self.tlv(id, value)
167
+ "#{id}#{value.length.to_s.rjust(2, '0')}#{value}"
168
+ end
169
+
170
+ private_class_method :tlv
171
+
172
+ # Generates a Pix BR Code payload.
173
+ #
174
+ # @param params [Hash] Exactly one of `:key` (static) or `:url`
175
+ # (dynamic) must be given, plus `:merchantName` and `:merchantCity`,
176
+ # and optionally `:amount`, `:txid` and `:description`.
177
+ # @return [String, nil] nil when the parameters are invalid or
178
+ # contradictory (e.g. both/neither `:key` and `:url` given).
179
+ #
180
+ # @note This has no acceptance test cases in the contract; it has only
181
+ # been self-verified (a generated payload passes {is_valid} and
182
+ # round-trips through {get_info}), not cross-checked against a
183
+ # reference implementation's own test suite.
184
+ def self.generate(params)
185
+ return nil unless params.is_a?(Hash)
186
+
187
+ key = params[:key] || params['key']
188
+ url = params[:url] || params['url']
189
+ return nil if key.nil? == url.nil?
190
+
191
+ merchant_name = params[:merchantName] || params['merchantName']
192
+ merchant_city = params[:merchantCity] || params['merchantCity']
193
+ return nil unless merchant_name.is_a?(String) && merchant_city.is_a?(String)
194
+ return nil if merchant_name.empty? || merchant_city.empty?
195
+
196
+ amount = params[:amount] || params['amount']
197
+ txid = params[:txid] || params['txid'] || '***'
198
+ description = params[:description] || params['description']
199
+
200
+ clean_url = nil
201
+ key_info = nil
202
+
203
+ if url
204
+ return nil unless url.is_a?(String)
205
+ return nil if amount || (params[:txid] || params['txid'])
206
+
207
+ clean_url = url.sub(%r{\Ahttps?://}, '')
208
+ return nil if clean_url.empty? || clean_url.length > 77
209
+ else
210
+ key_info = PixKeyUtils.get_info(key)
211
+ return nil unless key_info
212
+ end
213
+
214
+ amount_str = nil
215
+ if amount
216
+ return nil if amount.to_s.match?(/\.\d{3,}/)
217
+
218
+ amount_str = format('%.2f', amount.to_f)
219
+ return nil unless amount_str.to_f.positive?
220
+ end
221
+
222
+ return nil unless txid == '***' || txid.match?(/\A[A-Za-z0-9]{1,25}\z/)
223
+
224
+ name = TextUtils.remove_accents(merchant_name)[0, 25]
225
+ city = TextUtils.remove_accents(merchant_city)[0, 15]
226
+
227
+ merchant_account_value = tlv('00', PIX_GUI) + (url ? tlv('25', clean_url) : tlv('01', key_info[:value]))
228
+
229
+ fields = []
230
+ fields << tlv('00', '01')
231
+ fields << tlv('01', url ? '12' : '11')
232
+ fields << tlv('26', merchant_account_value)
233
+ fields << tlv('52', '0000')
234
+ fields << tlv('53', '986')
235
+ fields << tlv('54', amount_str) if amount_str && !url
236
+ fields << tlv('58', 'BR')
237
+ fields << tlv('59', name)
238
+ fields << tlv('60', city)
239
+
240
+ unless url
241
+ additional = tlv('05', txid)
242
+ if description
243
+ desc = TextUtils.remove_accents(description)[0, 99]
244
+ additional += tlv('02', desc) unless desc.empty?
245
+ end
246
+ fields << tlv('62', additional)
247
+ end
248
+
249
+ payload_without_crc = fields.join + '6304'
250
+ payload_without_crc + format('%04X', crc16(payload_without_crc))
251
+ end
252
+ end
253
+ end
@@ -0,0 +1,112 @@
1
+ module BrazilianUtils
2
+ # Utilities for checking the *structure* of a professional council
3
+ # registration number (OAB, CRM, CRO, CRP or CRC). This never validates a
4
+ # check digit — none of these councils publish one — only the digit
5
+ # count and the UF.
6
+ #
7
+ # @note There were no acceptance test cases available in the contract for
8
+ # this function; this implementation follows the prose description as
9
+ # closely as possible but has not been cross-checked against a
10
+ # reference implementation's test suite.
11
+ module RegistroProfissionalUtils
12
+ UFS = %w[
13
+ 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
14
+ ].freeze
15
+
16
+ # Checks the structure of a professional council registration number.
17
+ #
18
+ # @param params [Hash] `:value` (the registration string), `:council`
19
+ # (`"OAB"`, `"CRM"`, `"CRO"`, `"CRP"` or `"CRC"`) and an optional
20
+ # `:state` (the expected UF).
21
+ # @return [Boolean]
22
+ def self.is_valid(params)
23
+ return false unless params.is_a?(Hash)
24
+
25
+ value = params[:value] || params['value']
26
+ council = (params[:council] || params['council']).to_s.upcase
27
+ expected_state = params[:state] || params['state']
28
+
29
+ return false unless value.is_a?(String)
30
+
31
+ case council
32
+ when 'OAB', 'CRM'
33
+ valid_oab_crm?(value, expected_state)
34
+ when 'CRO'
35
+ valid_cro?(value, expected_state)
36
+ when 'CRP'
37
+ valid_crp?(value)
38
+ when 'CRC'
39
+ valid_crc?(value, expected_state)
40
+ else
41
+ false
42
+ end
43
+ end
44
+
45
+ class << self
46
+ alias valid? is_valid
47
+ end
48
+
49
+ # @private
50
+ def self.matches_expected_state?(uf, expected_state)
51
+ expected_state.nil? || expected_state.to_s.upcase == uf
52
+ end
53
+
54
+ private_class_method :matches_expected_state?
55
+
56
+ # OAB and CRM: 4 to 6 digits plus the UF (`123456/SP`, `123456-SP`).
57
+ #
58
+ # @private
59
+ def self.valid_oab_crm?(value, expected_state)
60
+ m = value.strip.match(%r{\A(\d{4,6})[/-]([A-Za-z]{2})\z})
61
+ return false unless m
62
+
63
+ uf = m[2].upcase
64
+ UFS.include?(uf) && matches_expected_state?(uf, expected_state)
65
+ end
66
+
67
+ private_class_method :valid_oab_crm?
68
+
69
+ # CRO: 3 to 6 digits plus the UF.
70
+ #
71
+ # @private
72
+ def self.valid_cro?(value, expected_state)
73
+ m = value.strip.match(%r{\A(\d{3,6})[/-]([A-Za-z]{2})\z})
74
+ return false unless m
75
+
76
+ uf = m[2].upcase
77
+ UFS.include?(uf) && matches_expected_state?(uf, expected_state)
78
+ end
79
+
80
+ private_class_method :valid_cro?
81
+
82
+ # CRP: a 2-digit regional code (01-24) plus 4 to 6 digits
83
+ # (`06/12345`); the expected state is ignored.
84
+ #
85
+ # @private
86
+ def self.valid_crp?(value)
87
+ m = value.strip.match(%r{\A(\d{2})[/-](\d{4,6})\z})
88
+ return false unless m
89
+
90
+ m[1].to_i.between?(1, 24)
91
+ end
92
+
93
+ private_class_method :valid_crp?
94
+
95
+ # CRC: UF, 6 digits, registration type (`O` or `P`) and a digit
96
+ # (`SP-123456/O-3`), optionally a transfer suffix (`T-MG` or `S-MG`).
97
+ #
98
+ # @private
99
+ def self.valid_crc?(value, expected_state)
100
+ m = value.strip.match(%r{\A([A-Za-z]{2})-(\d{6})/([OoPp])-(\d)(?:\s+[TtSs]-([A-Za-z]{2}))?\z})
101
+ return false unless m
102
+
103
+ uf = m[1].upcase
104
+ return false unless UFS.include?(uf)
105
+ return false if m[5] && !UFS.include?(m[5].upcase)
106
+
107
+ matches_expected_state?(uf, expected_state)
108
+ end
109
+
110
+ private_class_method :valid_crc?
111
+ end
112
+ end
@@ -109,5 +109,19 @@ module BrazilianUtils
109
109
  end
110
110
 
111
111
  private_class_method :calculate_renavam_dv
112
+
113
+ # Generates a valid random RENAVAM (11 digits: 10 base digits plus the
114
+ # check digit), unformatted.
115
+ #
116
+ # @return [String]
117
+ def self.generate
118
+ loop do
119
+ base = 10.times.map { rand(0..9) }.join
120
+ next if base.chars.uniq.length == 1
121
+
122
+ renavam = base + calculate_renavam_dv(base).to_s
123
+ return renavam if is_valid_renavam(renavam)
124
+ end
125
+ end
112
126
  end
113
127
  end
@@ -0,0 +1,105 @@
1
+ require 'json'
2
+ require_relative 'text-utils'
3
+
4
+ module BrazilianUtils
5
+ # Utilities for looking up Brazil's 27 federative units (states + the
6
+ # Distrito Federal): code (sigla), name, region and IBGE code, sourced
7
+ # from the IBGE Localidades API.
8
+ module StateUtils
9
+ DATA_FILE = File.join(File.dirname(__FILE__), 'data', 'states.json')
10
+
11
+ # @private
12
+ def self.load_data
13
+ @data ||= JSON.parse(File.read(DATA_FILE))
14
+ end
15
+
16
+ private_class_method :load_data
17
+
18
+ # @private
19
+ def self.normalize_name(name)
20
+ TextUtils.remove_accents(name.to_s).downcase.strip.gsub(/\s+/, ' ')
21
+ end
22
+
23
+ private_class_method :normalize_name
24
+
25
+ # @private
26
+ def self.to_symbolized(row)
27
+ {
28
+ code: row['code'],
29
+ name: row['name'],
30
+ regionCode: row['regionCode'],
31
+ regionName: row['regionName'],
32
+ ibgeCode: row['ibgeCode']
33
+ }
34
+ end
35
+
36
+ private_class_method :to_symbolized
37
+
38
+ # Returns the 27 Brazilian federative units, sorted by name (pt-BR
39
+ # collation, approximated by comparing accent-stripped names).
40
+ #
41
+ # @return [Array<Hash>] Each entry has `:code`, `:name`, `:regionCode`,
42
+ # `:regionName` and `:ibgeCode`.
43
+ def self.list
44
+ load_data
45
+ .sort_by { |row| normalize_name(row['name']) }
46
+ .map { |row| to_symbolized(row) }
47
+ end
48
+
49
+ # Returns the state whose 2-digit IBGE code (cUF) matches `code`.
50
+ #
51
+ # @param code [String, Integer]
52
+ # @return [Hash, nil]
53
+ def self.get_by_ibge_code(code)
54
+ return nil if code.nil?
55
+
56
+ str = code.to_s.strip
57
+ return nil unless str.match?(/\A\d+\z/)
58
+
59
+ row = load_data.find { |r| r['ibgeCode'] == str.to_i }
60
+ row && to_symbolized(row)
61
+ end
62
+
63
+ # Returns the two-letter code (sigla) of a state from its full name.
64
+ #
65
+ # @param name [String]
66
+ # @return [String, nil]
67
+ def self.get_code_by_name(name)
68
+ return nil unless name.is_a?(String)
69
+
70
+ normalized = normalize_name(name)
71
+ return nil if normalized.empty?
72
+
73
+ row = load_data.find { |r| normalize_name(r['name']) == normalized }
74
+ row && row['code']
75
+ end
76
+
77
+ # Returns the full name of a state from its two-letter code (sigla).
78
+ #
79
+ # @param code [String]
80
+ # @return [String, nil]
81
+ def self.get_name_by_code(code)
82
+ return nil unless code.is_a?(String)
83
+
84
+ normalized = code.strip.upcase
85
+ return nil if normalized.empty?
86
+
87
+ row = load_data.find { |r| r['code'] == normalized }
88
+ row && row['name']
89
+ end
90
+
91
+ # Returns the IANA time zone of a state (the zone of its capital).
92
+ #
93
+ # @param state_code [String]
94
+ # @return [String, nil]
95
+ def self.get_timezone(state_code)
96
+ return nil unless state_code.is_a?(String)
97
+
98
+ normalized = state_code.strip.upcase
99
+ return nil if normalized.empty?
100
+
101
+ row = load_data.find { |r| r['code'] == normalized }
102
+ row && row['timezone']
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BrazilianUtils
4
+ # General-purpose text helpers used across the other domains (and useful
5
+ # on their own) for handling names, company names and addresses written in
6
+ # Brazilian Portuguese.
7
+ module TextUtils
8
+ # Prepositions and articles that stay lower case between two words.
9
+ DEFAULT_PREPOSITIONS = %w[de da do das dos e].freeze
10
+
11
+ # Company designations/abbreviations that are always upper-cased.
12
+ # Deliberately conservative: common words that also happen to be valid
13
+ # abbreviations (e.g. "me", the reflexive pronoun; "sa", the surname
14
+ # "Sá") are left out on purpose, see `text.capitalize`'s description.
15
+ DEFAULT_DESIGNATIONS = %w[LTDA EPP MEI EIRELI CNPJ].freeze
16
+
17
+ # Roman numerals commonly used in Brazilian company/entity names
18
+ # (e.g. "Fundação XXI"). Only applied when the original token was
19
+ # already written fully upper case, to avoid mistaking short
20
+ # Portuguese words (like "vi", "mim") for numerals.
21
+ ROMAN_NUMERALS = %w[
22
+ I II III IV V VI VII VIII IX X XI XII XIII XIV XV XVI XVII XVIII XIX XX
23
+ ].freeze
24
+
25
+ WORD_OR_SEPARATOR_REGEX = /[\p{L}\p{N}]+|[^\p{L}\p{N}]+/.freeze
26
+
27
+ # Capitalizes the first letter of each word the way a Brazilian name,
28
+ # company name or address is written.
29
+ #
30
+ # @param value [String] The text to capitalize.
31
+ # @param options [Hash] `:prepositions` and `:designations` replace the
32
+ # default lists.
33
+ # @return [String] The capitalized text, or an empty string for empty
34
+ # input.
35
+ #
36
+ # @example
37
+ # capitalize("esponja vegetal") #=> "Esponja Vegetal"
38
+ # capitalize("fulano de tal") #=> "Fulano de Tal"
39
+ # capitalize("JOAQUIM JOSÉ") #=> "Joaquim José"
40
+ def self.capitalize(value, options = {})
41
+ return '' unless value.is_a?(String)
42
+ return '' if value.strip.empty?
43
+
44
+ prepositions = (options[:prepositions] || options['prepositions'] || DEFAULT_PREPOSITIONS).map(&:downcase)
45
+ designations = (options[:designations] || options['designations'] || DEFAULT_DESIGNATIONS).map(&:upcase)
46
+
47
+ collapsed = value.strip.gsub(/\s+/, ' ')
48
+ tokens = collapsed.scan(WORD_OR_SEPARATOR_REGEX)
49
+
50
+ word_indices = tokens.each_index.select { |i| tokens[i].match?(/\A[\p{L}\p{N}]+\z/) }
51
+ first_word_idx = word_indices.first
52
+ last_word_idx = word_indices.last
53
+
54
+ word_indices.each do |i|
55
+ token = tokens[i]
56
+
57
+ if token.match?(/\A\d/)
58
+ tokens[i] = token.downcase
59
+ next
60
+ end
61
+
62
+ upcase_token = token.upcase
63
+
64
+ if token == upcase_token && ROMAN_NUMERALS.include?(upcase_token)
65
+ tokens[i] = upcase_token
66
+ next
67
+ end
68
+
69
+ if designations.include?(upcase_token)
70
+ tokens[i] = upcase_token
71
+ next
72
+ end
73
+
74
+ downcase_token = token.downcase
75
+ next_token = tokens[i + 1]
76
+ followed_by_punctuation = !next_token.nil? && next_token != ' '
77
+
78
+ if prepositions.include?(downcase_token) && i != first_word_idx && i != last_word_idx && !followed_by_punctuation
79
+ tokens[i] = downcase_token
80
+ else
81
+ tokens[i] = token[0].upcase + token[1..-1].to_s.downcase
82
+ end
83
+ end
84
+
85
+ tokens.join
86
+ end
87
+
88
+ # Removes diacritical marks (accents, tildes, cedillas) from a string.
89
+ #
90
+ # Every character is decomposed (Unicode NFD) and every combining mark
91
+ # is dropped.
92
+ #
93
+ # @param value [String] The text to strip accents from.
94
+ # @return [String] The text without diacritics.
95
+ #
96
+ # @example
97
+ # remove_accents("São Paulo") #=> "Sao Paulo"
98
+ # remove_accents("Açaí") #=> "Acai"
99
+ def self.remove_accents(value)
100
+ return '' unless value.is_a?(String)
101
+
102
+ value.unicode_normalize(:nfd).gsub(/[̀-ͯ]/, '')
103
+ end
104
+ end
105
+ end