br-utils 0.1.1 → 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.
- checksums.yaml +4 -4
- data/README.md +446 -22
- data/lib/brazilian-utils/area-code-utils.rb +60 -0
- data/lib/brazilian-utils/bank-account-utils.rb +82 -0
- data/lib/brazilian-utils/bank-utils.rb +61 -0
- data/lib/brazilian-utils/boleto-utils.rb +139 -0
- data/lib/brazilian-utils/caepf-utils.rb +91 -0
- data/lib/brazilian-utils/cbo-utils.rb +74 -0
- data/lib/brazilian-utils/cei-utils.rb +79 -0
- data/lib/brazilian-utils/cep-utils.rb +25 -1
- data/lib/brazilian-utils/certidao-utils.rb +171 -0
- data/lib/brazilian-utils/cfop-utils.rb +74 -0
- data/lib/brazilian-utils/cnae-utils.rb +104 -0
- data/lib/brazilian-utils/cnh-utils.rb +53 -0
- data/lib/brazilian-utils/cno-utils.rb +76 -0
- data/lib/brazilian-utils/cnpj-utils.rb +125 -12
- data/lib/brazilian-utils/cns-utils.rb +110 -0
- data/lib/brazilian-utils/cpf-utils.rb +13 -0
- data/lib/brazilian-utils/credit-card-utils.rb +47 -0
- data/lib/brazilian-utils/csosn-utils.rb +55 -0
- data/lib/brazilian-utils/cst-utils.rb +103 -0
- data/lib/brazilian-utils/currency-utils.rb +347 -228
- data/lib/brazilian-utils/data/area_codes.json +676 -0
- data/lib/brazilian-utils/data/banks.json +357 -0
- data/lib/brazilian-utils/data/cbo.json +1 -0
- data/lib/brazilian-utils/data/cfop.json +1 -0
- data/lib/brazilian-utils/data/cnae.json +1 -0
- data/lib/brazilian-utils/data/csosn.json +42 -0
- data/lib/brazilian-utils/data/cst.json +294 -0
- data/lib/brazilian-utils/data/legal_nature.json +900 -0
- data/lib/brazilian-utils/data/municipalities.json +1 -0
- data/lib/brazilian-utils/data/ncm.json +1 -0
- data/lib/brazilian-utils/data/states.json +218 -0
- data/lib/brazilian-utils/date-utils.rb +504 -256
- data/lib/brazilian-utils/iban-utils.rb +120 -0
- data/lib/brazilian-utils/ie-utils.rb +84 -0
- data/lib/brazilian-utils/legal-nature-utils.rb +238 -235
- data/lib/brazilian-utils/legal-process-utils.rb +31 -3
- data/lib/brazilian-utils/license-plate-utils.rb +21 -5
- data/lib/brazilian-utils/municipality-utils.rb +58 -0
- data/lib/brazilian-utils/ncm-utils.rb +91 -0
- data/lib/brazilian-utils/nfe-key-utils.rb +158 -0
- data/lib/brazilian-utils/number-utils.rb +123 -0
- data/lib/brazilian-utils/passport-utils.rb +65 -0
- data/lib/brazilian-utils/phone-utils.rb +491 -280
- data/lib/brazilian-utils/pis-utils.rb +16 -1
- data/lib/brazilian-utils/pix-key-utils.rb +70 -0
- data/lib/brazilian-utils/pix-payload-utils.rb +253 -0
- data/lib/brazilian-utils/registro-profissional-utils.rb +112 -0
- data/lib/brazilian-utils/renavam-utils.rb +14 -0
- data/lib/brazilian-utils/state-utils.rb +105 -0
- data/lib/brazilian-utils/text-utils.rb +105 -0
- data/lib/brazilian-utils/vin-utils.rb +46 -0
- data/lib/brazilian-utils/voter-id-utils.rb +36 -0
- metadata +39 -1
|
@@ -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
|