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.
data/lib/sec_id/base.rb CHANGED
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module SecId
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 :full_number
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
- class << self
49
- # @param id [String] the identifier to validate
50
- # @return [Boolean]
51
- def valid?(id)
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, upcase: true)
94
- @full_number = sec_id_number.to_s.strip
95
- @full_number.upcase! if upcase
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 SecId
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
- # SecId::CEI.valid?('A0BCDEFGH1') #=> true
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 SecId
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
- # SecId::CFI.valid?('ESXXXX') #=> true
16
- # SecId::CFI.valid?('ESVUFR') #=> true
15
+ # SecID::CFI.valid?('ESXXXX') #=> true
16
+ # SecID::CFI.valid?('ESVUFR') #=> true
17
17
  #
18
18
  # @example Access CFI components
19
- # cfi = SecId::CFI.new('ESVUFR')
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) != nil
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 SecId
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. The {#has_check_digit?} method
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
- # SecId::CIK.valid?('0001521365') #=> true
14
- # SecId::CIK.valid?('1521365') #=> true
12
+ # SecID::CIK.valid?('0001521365') #=> true
13
+ # SecID::CIK.valid?('1521365') #=> true
15
14
  #
16
15
  # @example Normalize a CIK to 10 digits
17
- # SecId::CIK.normalize!('1521365') #=> '0001521365'
16
+ # SecID::CIK.normalize('1521365') #=> '0001521365'
18
17
  class CIK < Base
19
- include Normalizable
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] if the CIK format is invalid
41
- def normalize!
42
- raise InvalidFormatError, "CIK '#{full_number}' is invalid and cannot be normalized!" unless valid_format?
39
+ # @raise [InvalidFormatError]
40
+ def normalized
41
+ validate!
42
+ @identifier.rjust(self.class::ID_LENGTH.max, '0')
43
+ end
43
44
 
44
- @full_number = @identifier.rjust(10, '0')
45
- @padding = @full_number[0, 10 - @identifier.length]
46
- @full_number
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
- full_number
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 SecId
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 the check digit
14
- # - `restore!` method to calculate and set the check digit
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
- # Restores (calculates) the check digit and returns the full identifier.
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
- return false unless valid_format?
88
-
89
- check_digit == calculate_check_digit
97
+ super && check_digit == calculate_check_digit
90
98
  end
91
99
 
92
- # Calculates and sets the check digit, updating full_number.
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
- @full_number = to_s
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
- alias to_str to_s
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
- private
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} '#{full_number}' is invalid and check-digit cannot be calculated!"
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 SecId
4
- # Provides normalize! class method delegation for identifiers that support normalization.
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
- # @example
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] if the identifier format is invalid
30
- def normalize!(id)
31
- new(id).normalize!
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