sec_id 4.4.0 → 5.0.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/CHANGELOG.md +55 -3
- data/MIGRATION.md +257 -0
- data/README.md +234 -142
- data/lib/sec_id/base.rb +14 -38
- data/lib/sec_id/cei.rb +7 -2
- data/lib/sec_id/cfi.rb +38 -12
- data/lib/sec_id/cik.rb +21 -18
- data/lib/sec_id/concerns/checkable.rb +56 -14
- data/lib/sec_id/concerns/identifier_metadata.rb +56 -0
- data/lib/sec_id/concerns/normalizable.rb +42 -16
- data/lib/sec_id/concerns/validatable.rb +158 -0
- data/lib/sec_id/cusip.rb +10 -8
- data/lib/sec_id/detector.rb +156 -0
- data/lib/sec_id/errors.rb +67 -0
- data/lib/sec_id/figi.rb +30 -10
- data/lib/sec_id/fisn.rb +10 -4
- data/lib/sec_id/iban/country_rules.rb +4 -2
- data/lib/sec_id/iban.rb +44 -12
- data/lib/sec_id/isin.rb +11 -6
- data/lib/sec_id/lei.rb +12 -9
- data/lib/sec_id/occ.rb +40 -25
- data/lib/sec_id/sedol.rb +9 -7
- data/lib/sec_id/valoren.rb +22 -22
- data/lib/sec_id/version.rb +2 -2
- data/lib/sec_id/wkn.rb +10 -7
- data/lib/sec_id.rb +127 -6
- data/sec_id.gemspec +6 -3
- metadata +11 -3
data/lib/sec_id/base.rb
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
3
|
+
module SecID
|
|
4
4
|
# Base class for securities identifiers that provides a common interface
|
|
5
|
-
# for validation and parsing.
|
|
5
|
+
# for validation, normalization, and parsing.
|
|
6
6
|
#
|
|
7
7
|
# Subclasses must implement:
|
|
8
8
|
# - ID_REGEX constant with named capture groups for parsing
|
|
@@ -39,24 +39,20 @@ module SecId
|
|
|
39
39
|
# end
|
|
40
40
|
# end
|
|
41
41
|
class Base
|
|
42
|
+
include IdentifierMetadata
|
|
43
|
+
include Normalizable
|
|
44
|
+
include Validatable
|
|
45
|
+
|
|
42
46
|
# @return [String] the original input after normalization (stripped and uppercased)
|
|
43
|
-
attr_reader :
|
|
47
|
+
attr_reader :full_id
|
|
44
48
|
|
|
45
49
|
# @return [String, nil] the main identifier portion (without check digit)
|
|
46
50
|
attr_reader :identifier
|
|
47
51
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
new(id).valid?
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
# @param id [String] the identifier to check
|
|
56
|
-
# @return [Boolean]
|
|
57
|
-
def valid_format?(id)
|
|
58
|
-
new(id).valid_format?
|
|
59
|
-
end
|
|
52
|
+
# @api private
|
|
53
|
+
def self.inherited(subclass)
|
|
54
|
+
super
|
|
55
|
+
SecID.__send__(:register_identifier, subclass) if subclass.name&.start_with?('SecID::')
|
|
60
56
|
end
|
|
61
57
|
|
|
62
58
|
# Subclasses must override this method.
|
|
@@ -67,33 +63,13 @@ module SecId
|
|
|
67
63
|
raise NotImplementedError
|
|
68
64
|
end
|
|
69
65
|
|
|
70
|
-
# @return [Boolean]
|
|
71
|
-
def valid?
|
|
72
|
-
valid_format?
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
# Override in subclasses for additional format validation.
|
|
76
|
-
#
|
|
77
|
-
# @return [Boolean]
|
|
78
|
-
def valid_format?
|
|
79
|
-
!identifier.nil?
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
# @return [String]
|
|
83
|
-
def to_s
|
|
84
|
-
identifier.to_s
|
|
85
|
-
end
|
|
86
|
-
alias to_str to_s
|
|
87
|
-
|
|
88
66
|
private
|
|
89
67
|
|
|
90
68
|
# @param sec_id_number [String, #to_s] the identifier to parse
|
|
91
|
-
# @param upcase [Boolean] whether to upcase the input
|
|
92
69
|
# @return [MatchData, Hash] the regex match data or empty hash if no match
|
|
93
|
-
def parse(sec_id_number
|
|
94
|
-
@
|
|
95
|
-
@
|
|
96
|
-
@full_number.match(self.class::ID_REGEX) || {}
|
|
70
|
+
def parse(sec_id_number)
|
|
71
|
+
@full_id = sec_id_number.to_s.strip.upcase
|
|
72
|
+
@full_id.match(self.class::ID_REGEX) || {}
|
|
97
73
|
end
|
|
98
74
|
end
|
|
99
75
|
end
|
data/lib/sec_id/cei.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
3
|
+
module SecID
|
|
4
4
|
# CUSIP Entity Identifier (CEI) - a 10-character alphanumeric code that identifies
|
|
5
5
|
# legal entities in the syndicated loan market.
|
|
6
6
|
#
|
|
@@ -9,10 +9,15 @@ module SecId
|
|
|
9
9
|
# @see https://www.cusip.com/identifiers.html
|
|
10
10
|
#
|
|
11
11
|
# @example Validate a CEI
|
|
12
|
-
#
|
|
12
|
+
# SecID::CEI.valid?('A0BCDEFGH1') #=> true
|
|
13
13
|
class CEI < Base
|
|
14
14
|
include Checkable
|
|
15
15
|
|
|
16
|
+
FULL_NAME = 'CUSIP Entity Identifier'
|
|
17
|
+
ID_LENGTH = 10
|
|
18
|
+
EXAMPLE = 'A0BCDEFGH1'
|
|
19
|
+
VALID_CHARS_REGEX = /\A[A-Z0-9]+\z/
|
|
20
|
+
|
|
16
21
|
# Regular expression for parsing CEI components.
|
|
17
22
|
ID_REGEX = /\A
|
|
18
23
|
(?<identifier>
|
data/lib/sec_id/cfi.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
3
|
+
module SecID
|
|
4
4
|
# Classification of Financial Instruments (CFI) - a 6-character alphabetic code
|
|
5
5
|
# that classifies financial instruments per ISO 10962.
|
|
6
6
|
#
|
|
@@ -12,15 +12,20 @@ module SecId
|
|
|
12
12
|
# @see https://en.wikipedia.org/wiki/ISO_10962
|
|
13
13
|
#
|
|
14
14
|
# @example Validate a CFI code
|
|
15
|
-
#
|
|
16
|
-
#
|
|
15
|
+
# SecID::CFI.valid?('ESXXXX') #=> true
|
|
16
|
+
# SecID::CFI.valid?('ESVUFR') #=> true
|
|
17
17
|
#
|
|
18
18
|
# @example Access CFI components
|
|
19
|
-
# cfi =
|
|
19
|
+
# cfi = SecID::CFI.new('ESVUFR')
|
|
20
20
|
# cfi.category #=> :equity
|
|
21
21
|
# cfi.group #=> :common_shares
|
|
22
22
|
# cfi.voting? #=> true
|
|
23
23
|
class CFI < Base
|
|
24
|
+
FULL_NAME = 'Classification of Financial Instruments'
|
|
25
|
+
ID_LENGTH = 6
|
|
26
|
+
EXAMPLE = 'ESVUFR'
|
|
27
|
+
VALID_CHARS_REGEX = /\A[A-Z]+\z/
|
|
28
|
+
|
|
24
29
|
# Regular expression for parsing CFI components.
|
|
25
30
|
ID_REGEX = /\A
|
|
26
31
|
(?<identifier>
|
|
@@ -191,13 +196,6 @@ module SecId
|
|
|
191
196
|
@attr4 = cfi_parts[:attr4]
|
|
192
197
|
end
|
|
193
198
|
|
|
194
|
-
# Validates format including category and group codes.
|
|
195
|
-
#
|
|
196
|
-
# @return [Boolean]
|
|
197
|
-
def valid_format?
|
|
198
|
-
super && valid_category? && valid_group?
|
|
199
|
-
end
|
|
200
|
-
|
|
201
199
|
# Returns the semantic category name.
|
|
202
200
|
#
|
|
203
201
|
# @return [Symbol, nil] category symbol or nil if invalid
|
|
@@ -301,6 +299,34 @@ module SecId
|
|
|
301
299
|
|
|
302
300
|
private
|
|
303
301
|
|
|
302
|
+
# @return [Boolean]
|
|
303
|
+
def valid_format?
|
|
304
|
+
super && valid_category? && valid_group?
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# @return [Array<Symbol>]
|
|
308
|
+
def detect_errors
|
|
309
|
+
return super unless identifier
|
|
310
|
+
|
|
311
|
+
errors = []
|
|
312
|
+
errors << :invalid_category unless valid_category?
|
|
313
|
+
errors << :invalid_group unless valid_group?
|
|
314
|
+
errors
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# @param code [Symbol]
|
|
318
|
+
# @return [String]
|
|
319
|
+
def validation_message(code)
|
|
320
|
+
case code
|
|
321
|
+
when :invalid_category
|
|
322
|
+
"Category '#{category_code}' is not a valid CFI category"
|
|
323
|
+
when :invalid_group
|
|
324
|
+
"Group '#{group_code}' is not valid for category '#{category_code}'"
|
|
325
|
+
else
|
|
326
|
+
super
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
304
330
|
# @return [Boolean]
|
|
305
331
|
def valid_category?
|
|
306
332
|
CATEGORIES.key?(category_code)
|
|
@@ -308,7 +334,7 @@ module SecId
|
|
|
308
334
|
|
|
309
335
|
# @return [Boolean]
|
|
310
336
|
def valid_group?
|
|
311
|
-
GROUPS.dig(category_code, group_code)
|
|
337
|
+
!GROUPS.dig(category_code, group_code).nil?
|
|
312
338
|
end
|
|
313
339
|
end
|
|
314
340
|
end
|
data/lib/sec_id/cik.rb
CHANGED
|
@@ -1,22 +1,24 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
3
|
+
module SecID
|
|
4
4
|
# Central Index Key (CIK) - SEC identifier for entities filing with the SEC.
|
|
5
5
|
# A 1-10 digit number that uniquely identifies entities in SEC systems.
|
|
6
6
|
#
|
|
7
|
-
# @note CIK identifiers have no check digit
|
|
8
|
-
# returns false and validation is based solely on format.
|
|
7
|
+
# @note CIK identifiers have no check digit and validation is based solely on format.
|
|
9
8
|
#
|
|
10
9
|
# @see https://en.wikipedia.org/wiki/Central_Index_Key
|
|
11
10
|
#
|
|
12
11
|
# @example Validate a CIK
|
|
13
|
-
#
|
|
14
|
-
#
|
|
12
|
+
# SecID::CIK.valid?('0001521365') #=> true
|
|
13
|
+
# SecID::CIK.valid?('1521365') #=> true
|
|
15
14
|
#
|
|
16
15
|
# @example Normalize a CIK to 10 digits
|
|
17
|
-
#
|
|
16
|
+
# SecID::CIK.normalize('1521365') #=> '0001521365'
|
|
18
17
|
class CIK < Base
|
|
19
|
-
|
|
18
|
+
FULL_NAME = 'Central Index Key'
|
|
19
|
+
ID_LENGTH = (1..10)
|
|
20
|
+
EXAMPLE = '0001521365'
|
|
21
|
+
VALID_CHARS_REGEX = /\A[0-9]+\z/
|
|
20
22
|
|
|
21
23
|
# Regular expression for parsing CIK components.
|
|
22
24
|
ID_REGEX = /\A
|
|
@@ -33,23 +35,24 @@ module SecId
|
|
|
33
35
|
@identifier = cik_parts[:identifier]
|
|
34
36
|
end
|
|
35
37
|
|
|
36
|
-
# Normalizes the CIK to a 10-digit zero-padded format.
|
|
37
|
-
# Updates both @full_number and @padding to reflect the normalized state.
|
|
38
|
-
#
|
|
39
38
|
# @return [String] the normalized 10-digit CIK
|
|
40
|
-
# @raise [InvalidFormatError]
|
|
41
|
-
def
|
|
42
|
-
|
|
39
|
+
# @raise [InvalidFormatError]
|
|
40
|
+
def normalized
|
|
41
|
+
validate!
|
|
42
|
+
@identifier.rjust(self.class::ID_LENGTH.max, '0')
|
|
43
|
+
end
|
|
43
44
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
# @return [self]
|
|
46
|
+
# @raise [InvalidFormatError]
|
|
47
|
+
def normalize!
|
|
48
|
+
super
|
|
49
|
+
@padding = @full_id[0, self.class::ID_LENGTH.max - @identifier.length]
|
|
50
|
+
self
|
|
47
51
|
end
|
|
48
52
|
|
|
49
53
|
# @return [String]
|
|
50
54
|
def to_s
|
|
51
|
-
|
|
55
|
+
full_id
|
|
52
56
|
end
|
|
53
|
-
alias to_str to_s
|
|
54
57
|
end
|
|
55
58
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
3
|
+
module SecID
|
|
4
4
|
# Provides check-digit validation and calculation for securities identifiers.
|
|
5
5
|
# Include this module in classes that have a check digit as part of their format.
|
|
6
6
|
#
|
|
@@ -10,10 +10,11 @@ module SecId
|
|
|
10
10
|
# This module provides:
|
|
11
11
|
# - Character-to-digit mapping constants
|
|
12
12
|
# - Luhn algorithm variants for check-digit calculation
|
|
13
|
-
# - `valid?` override that validates
|
|
14
|
-
# - `restore
|
|
13
|
+
# - `valid?` override that validates format and check digit
|
|
14
|
+
# - `restore` method returning full identifier string without mutation
|
|
15
|
+
# - `restore!` method to calculate and set the check digit, returning `self`
|
|
15
16
|
# - `check_digit` attribute
|
|
16
|
-
# - Class-level convenience methods: `restore!`, `check_digit`
|
|
17
|
+
# - Class-level convenience methods: `restore`, `restore!`, `check_digit`
|
|
17
18
|
#
|
|
18
19
|
# @example Including in an identifier class
|
|
19
20
|
# class MyIdentifier < Base
|
|
@@ -63,11 +64,20 @@ module SecId
|
|
|
63
64
|
|
|
64
65
|
# Class methods added when Checkable is included.
|
|
65
66
|
module ClassMethods
|
|
66
|
-
#
|
|
67
|
+
# Returns the full identifier string with correct check digit.
|
|
67
68
|
#
|
|
68
69
|
# @param id_without_check_digit [String] identifier without or with incorrect check digit
|
|
69
70
|
# @return [String] the full identifier with correct check digit
|
|
70
71
|
# @raise [InvalidFormatError] if the identifier format is invalid
|
|
72
|
+
def restore(id_without_check_digit)
|
|
73
|
+
new(id_without_check_digit).restore
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Restores (calculates) the check digit and returns the instance.
|
|
77
|
+
#
|
|
78
|
+
# @param id_without_check_digit [String] identifier without or with incorrect check digit
|
|
79
|
+
# @return [self] the restored instance with correct check digit
|
|
80
|
+
# @raise [InvalidFormatError] if the identifier format is invalid
|
|
71
81
|
def restore!(id_without_check_digit)
|
|
72
82
|
new(id_without_check_digit).restore!
|
|
73
83
|
end
|
|
@@ -84,18 +94,25 @@ module SecId
|
|
|
84
94
|
#
|
|
85
95
|
# @return [Boolean]
|
|
86
96
|
def valid?
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
check_digit == calculate_check_digit
|
|
97
|
+
super && check_digit == calculate_check_digit
|
|
90
98
|
end
|
|
91
99
|
|
|
92
|
-
#
|
|
100
|
+
# Returns the full identifier string with correct check digit without mutation.
|
|
93
101
|
#
|
|
94
102
|
# @return [String] the full identifier with correct check digit
|
|
95
103
|
# @raise [InvalidFormatError] if the identifier format is invalid
|
|
104
|
+
def restore
|
|
105
|
+
"#{identifier}#{calculate_check_digit.to_s.rjust(check_digit_width, '0')}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Calculates and sets the check digit, updating full_id.
|
|
109
|
+
#
|
|
110
|
+
# @return [self]
|
|
111
|
+
# @raise [InvalidFormatError] if the identifier format is invalid
|
|
96
112
|
def restore!
|
|
97
113
|
@check_digit = calculate_check_digit
|
|
98
|
-
@
|
|
114
|
+
@full_id = to_s
|
|
115
|
+
self
|
|
99
116
|
end
|
|
100
117
|
|
|
101
118
|
# Subclasses must override this method to implement their check-digit algorithm.
|
|
@@ -109,9 +126,10 @@ module SecId
|
|
|
109
126
|
|
|
110
127
|
# @return [String]
|
|
111
128
|
def to_s
|
|
112
|
-
"#{identifier}#{check_digit}"
|
|
129
|
+
"#{identifier}#{check_digit&.to_s&.rjust(check_digit_width, '0')}"
|
|
113
130
|
end
|
|
114
|
-
|
|
131
|
+
|
|
132
|
+
private
|
|
115
133
|
|
|
116
134
|
# CUSIP/CEI style: "Double Add Double" algorithm.
|
|
117
135
|
# Processes pairs of digits, doubling the first (even-positioned from right),
|
|
@@ -170,14 +188,38 @@ module SecId
|
|
|
170
188
|
id.each_char.flat_map { |c| CHAR_TO_DIGITS.fetch(c) }.reverse!
|
|
171
189
|
end
|
|
172
190
|
|
|
173
|
-
|
|
191
|
+
# Returns error codes including check digit validation.
|
|
192
|
+
#
|
|
193
|
+
# @return [Array<Symbol>]
|
|
194
|
+
def error_codes
|
|
195
|
+
return detect_errors unless valid_format?
|
|
196
|
+
return [:invalid_check_digit] unless check_digit == calculate_check_digit
|
|
197
|
+
|
|
198
|
+
[]
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# @return [Integer]
|
|
202
|
+
def check_digit_width
|
|
203
|
+
1
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# @param code [Symbol]
|
|
207
|
+
# @return [String]
|
|
208
|
+
def validation_message(code)
|
|
209
|
+
if code == :invalid_check_digit
|
|
210
|
+
fmt = ->(cd) { cd.to_s.rjust(check_digit_width, '0') }
|
|
211
|
+
return "Check digit '#{fmt[check_digit]}' is invalid, expected '#{fmt[calculate_check_digit]}'"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
super
|
|
215
|
+
end
|
|
174
216
|
|
|
175
217
|
# @raise [InvalidFormatError] if valid_format? returns false
|
|
176
218
|
# @return [void]
|
|
177
219
|
def validate_format_for_calculation!
|
|
178
220
|
return if valid_format?
|
|
179
221
|
|
|
180
|
-
raise InvalidFormatError, "#{self.class.name} '#{
|
|
222
|
+
raise InvalidFormatError, "#{self.class.name} '#{full_id}' is invalid and check-digit cannot be calculated!"
|
|
181
223
|
end
|
|
182
224
|
|
|
183
225
|
# @param sum [Integer] the sum to calculate check digit from
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SecID
|
|
4
|
+
# Provides class-level metadata methods for identifier types.
|
|
5
|
+
#
|
|
6
|
+
# Including classes must define constants: `FULL_NAME`, `ID_LENGTH`, `EXAMPLE`.
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# SecID::ISIN.short_name #=> "ISIN"
|
|
10
|
+
# SecID::ISIN.full_name #=> "International Securities Identification Number"
|
|
11
|
+
# SecID::ISIN.has_check_digit? #=> true
|
|
12
|
+
module IdentifierMetadata
|
|
13
|
+
# @api private
|
|
14
|
+
def self.included(base)
|
|
15
|
+
base.extend(ClassMethods)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Class methods added when IdentifierMetadata is included.
|
|
19
|
+
module ClassMethods
|
|
20
|
+
# Returns the unqualified class name (e.g. "ISIN", "CUSIP").
|
|
21
|
+
#
|
|
22
|
+
# @return [String]
|
|
23
|
+
def short_name
|
|
24
|
+
@short_name ||= name.split('::').last
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Returns the full human-readable standard name.
|
|
28
|
+
#
|
|
29
|
+
# @return [String]
|
|
30
|
+
def full_name
|
|
31
|
+
self::FULL_NAME
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Returns the fixed length or valid length range for identifiers of this type.
|
|
35
|
+
#
|
|
36
|
+
# @return [Integer, Range]
|
|
37
|
+
def id_length
|
|
38
|
+
self::ID_LENGTH
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Returns a representative valid identifier string.
|
|
42
|
+
#
|
|
43
|
+
# @return [String]
|
|
44
|
+
def example
|
|
45
|
+
self::EXAMPLE
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @return [Boolean] true if this identifier type uses a check digit
|
|
49
|
+
def has_check_digit?
|
|
50
|
+
return @has_check_digit if defined?(@has_check_digit)
|
|
51
|
+
|
|
52
|
+
@has_check_digit = ancestors.include?(SecID::Checkable)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -1,20 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
4
|
-
# Provides
|
|
5
|
-
# Include this module in classes that implement an instance-level normalize! method.
|
|
3
|
+
module SecID
|
|
4
|
+
# Provides normalization methods for identifier types.
|
|
6
5
|
#
|
|
7
|
-
#
|
|
8
|
-
# class MyIdentifier < Base
|
|
9
|
-
# include Normalizable
|
|
10
|
-
#
|
|
11
|
-
# def normalize!
|
|
12
|
-
# # implementation
|
|
13
|
-
# end
|
|
14
|
-
# end
|
|
15
|
-
#
|
|
16
|
-
# MyIdentifier.normalize!('ABC123') #=> normalized string
|
|
6
|
+
# Including classes may override `SEPARATORS` (default `/[\s-]/`) and `#normalized`.
|
|
17
7
|
module Normalizable
|
|
8
|
+
SEPARATORS = /[\s-]/
|
|
9
|
+
|
|
18
10
|
# @api private
|
|
19
11
|
def self.included(base)
|
|
20
12
|
base.extend(ClassMethods)
|
|
@@ -26,10 +18,44 @@ module SecId
|
|
|
26
18
|
#
|
|
27
19
|
# @param id [String, #to_s] the identifier to normalize
|
|
28
20
|
# @return [String] the normalized identifier
|
|
29
|
-
# @raise [InvalidFormatError
|
|
30
|
-
def normalize
|
|
31
|
-
|
|
21
|
+
# @raise [InvalidFormatError, InvalidCheckDigitError, InvalidStructureError]
|
|
22
|
+
def normalize(id)
|
|
23
|
+
cleaned = id.to_s.strip.gsub(self::SEPARATORS, '')
|
|
24
|
+
new(cleaned.upcase).normalized
|
|
32
25
|
end
|
|
33
26
|
end
|
|
27
|
+
|
|
28
|
+
# Returns the canonical normalized form of this identifier.
|
|
29
|
+
#
|
|
30
|
+
# @return [String]
|
|
31
|
+
# @raise [InvalidFormatError, InvalidCheckDigitError, InvalidStructureError]
|
|
32
|
+
def normalized
|
|
33
|
+
validate!
|
|
34
|
+
to_s
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @!method normalize
|
|
38
|
+
# @return [String]
|
|
39
|
+
# @raise [InvalidFormatError, InvalidCheckDigitError, InvalidStructureError]
|
|
40
|
+
alias normalize normalized
|
|
41
|
+
|
|
42
|
+
# Normalizes this identifier in place, updating {#full_id}.
|
|
43
|
+
#
|
|
44
|
+
# @return [self]
|
|
45
|
+
# @raise [InvalidFormatError, InvalidCheckDigitError, InvalidStructureError]
|
|
46
|
+
def normalize!
|
|
47
|
+
@full_id = normalized
|
|
48
|
+
self
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [String]
|
|
52
|
+
def to_s
|
|
53
|
+
identifier.to_s
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [String]
|
|
57
|
+
def to_str
|
|
58
|
+
to_s
|
|
59
|
+
end
|
|
34
60
|
end
|
|
35
61
|
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SecID
|
|
4
|
+
# Provides validation methods for identifier types.
|
|
5
|
+
#
|
|
6
|
+
# Including classes should override `#valid_format?` and optionally `#detect_errors`
|
|
7
|
+
# for type-specific validation.
|
|
8
|
+
module Validatable
|
|
9
|
+
ERROR_MAP = {
|
|
10
|
+
invalid_check_digit: InvalidCheckDigitError,
|
|
11
|
+
invalid_prefix: InvalidStructureError,
|
|
12
|
+
invalid_category: InvalidStructureError,
|
|
13
|
+
invalid_group: InvalidStructureError,
|
|
14
|
+
invalid_bban: InvalidStructureError,
|
|
15
|
+
invalid_date: InvalidStructureError
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
# @api private
|
|
19
|
+
def self.included(base)
|
|
20
|
+
base.extend(ClassMethods)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Class methods added when Validatable is included.
|
|
24
|
+
module ClassMethods
|
|
25
|
+
# @param id [String] the identifier to validate
|
|
26
|
+
# @return [Boolean]
|
|
27
|
+
def valid?(id)
|
|
28
|
+
new(id).valid?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Validates the identifier and returns the instance (with errors cached).
|
|
32
|
+
#
|
|
33
|
+
# @param id [String] the identifier to validate
|
|
34
|
+
# @return [Base] the identifier instance
|
|
35
|
+
def validate(id)
|
|
36
|
+
new(id).validate
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Validates the identifier, raising an exception if invalid.
|
|
40
|
+
#
|
|
41
|
+
# @param id [String] the identifier to validate
|
|
42
|
+
# @return [Base] the identifier instance
|
|
43
|
+
# @raise [InvalidFormatError, InvalidCheckDigitError, InvalidStructureError]
|
|
44
|
+
def validate!(id)
|
|
45
|
+
new(id).validate!
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Maps an error code symbol to its corresponding exception class.
|
|
49
|
+
#
|
|
50
|
+
# @param code [Symbol]
|
|
51
|
+
# @return [Class]
|
|
52
|
+
def error_class_for(code)
|
|
53
|
+
ERROR_MAP.fetch(code, InvalidFormatError)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [Boolean]
|
|
58
|
+
def valid?
|
|
59
|
+
valid_format?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Eagerly triggers validation and caches errors.
|
|
63
|
+
#
|
|
64
|
+
# @return [self]
|
|
65
|
+
def validate
|
|
66
|
+
errors
|
|
67
|
+
self
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Returns an {Errors} object with error codes and human-readable messages.
|
|
71
|
+
#
|
|
72
|
+
# @return [Errors]
|
|
73
|
+
def errors
|
|
74
|
+
return @errors if defined?(@errors)
|
|
75
|
+
|
|
76
|
+
@errors = Errors.new(error_codes.map { |code| build_error(code, validation_message(code)) })
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Validates and returns self if valid, raises an exception otherwise.
|
|
80
|
+
#
|
|
81
|
+
# @return [self]
|
|
82
|
+
# @raise [InvalidFormatError, InvalidCheckDigitError, InvalidStructureError]
|
|
83
|
+
def validate!
|
|
84
|
+
return self if valid?
|
|
85
|
+
|
|
86
|
+
detail = errors.details.first
|
|
87
|
+
raise self.class.error_class_for(detail[:error]), detail[:message]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# Override in subclasses for additional format validation.
|
|
93
|
+
#
|
|
94
|
+
# @return [Boolean]
|
|
95
|
+
def valid_format?
|
|
96
|
+
!identifier.nil?
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Returns an array of error code symbols describing why validation failed.
|
|
100
|
+
#
|
|
101
|
+
# @return [Array<Symbol>]
|
|
102
|
+
def error_codes
|
|
103
|
+
return [] if valid_format?
|
|
104
|
+
|
|
105
|
+
detect_errors
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Three-stage fallback for format error detection: length, characters, then structure.
|
|
109
|
+
#
|
|
110
|
+
# @return [Array<Symbol>]
|
|
111
|
+
def detect_errors
|
|
112
|
+
return [:invalid_length] unless valid_length?
|
|
113
|
+
return [:invalid_characters] unless valid_characters?
|
|
114
|
+
|
|
115
|
+
[:invalid_format]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @return [Boolean]
|
|
119
|
+
def valid_length?
|
|
120
|
+
return false if full_id.empty?
|
|
121
|
+
|
|
122
|
+
id_length = self.class::ID_LENGTH
|
|
123
|
+
expected = id_length.is_a?(Range) ? id_length : ((id_length - check_digit_width)..id_length)
|
|
124
|
+
expected.cover?(full_id.length)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @return [Boolean]
|
|
128
|
+
def valid_characters?
|
|
129
|
+
full_id.match?(self.class::VALID_CHARS_REGEX)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @return [Integer] width of the check digit (0 for non-checkable, overridden in Checkable)
|
|
133
|
+
def check_digit_width
|
|
134
|
+
0
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# @param code [Symbol] error code
|
|
138
|
+
# @return [String] human-readable error message
|
|
139
|
+
def validation_message(code)
|
|
140
|
+
case code
|
|
141
|
+
when :invalid_length
|
|
142
|
+
expected = self.class::ID_LENGTH
|
|
143
|
+
"Expected #{expected} characters, got #{full_id.length}"
|
|
144
|
+
when :invalid_characters
|
|
145
|
+
"Contains invalid characters for #{self.class.short_name}"
|
|
146
|
+
when :invalid_format
|
|
147
|
+
"Does not match #{self.class.short_name} format"
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# @param code [Symbol]
|
|
152
|
+
# @param message [String]
|
|
153
|
+
# @return [Hash{Symbol => Symbol, String}]
|
|
154
|
+
def build_error(code, message)
|
|
155
|
+
{ error: code, message: message }.freeze
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|