sec_id 4.4.1 → 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/cusip.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module SecId
3
+ module SecID
4
4
  # Committee on Uniform Securities Identification Procedures (CUSIP) - a 9-character
5
5
  # alphanumeric code that identifies North American securities.
6
6
  #
@@ -9,14 +9,19 @@ module SecId
9
9
  # @see https://en.wikipedia.org/wiki/CUSIP
10
10
  #
11
11
  # @example Validate a CUSIP
12
- # SecId::CUSIP.valid?('037833100') #=> true
12
+ # SecID::CUSIP.valid?('037833100') #=> true
13
13
  #
14
14
  # @example Convert to ISIN
15
- # cusip = SecId::CUSIP.new('037833100')
16
- # cusip.to_isin('US') #=> #<SecId::ISIN>
15
+ # cusip = SecID::CUSIP.new('037833100')
16
+ # cusip.to_isin('US') #=> #<SecID::ISIN>
17
17
  class CUSIP < Base
18
18
  include Checkable
19
19
 
20
+ FULL_NAME = 'Committee on Uniform Securities Identification Procedures'
21
+ ID_LENGTH = 9
22
+ EXAMPLE = '037833100'
23
+ VALID_CHARS_REGEX = /\A[A-Z0-9*@#]+\z/
24
+
20
25
  # Regular expression for parsing CUSIP components.
21
26
  ID_REGEX = /\A
22
27
  (?<identifier>
@@ -55,10 +60,7 @@ module SecId
55
60
  raise(InvalidFormatError, "'#{country_code}' is not a CGS country code!")
56
61
  end
57
62
 
58
- cusip_with_check_digit = "#{identifier}#{check_digit || calculate_check_digit}"
59
- isin = ISIN.new(country_code + cusip_with_check_digit)
60
- isin.restore!
61
- isin
63
+ ISIN.new(country_code + restore).restore!
62
64
  end
63
65
 
64
66
  # @return [Boolean] true if first character is a letter (CINS identifier)
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SecID
4
+ # Detects which identifier types match a given string using a three-stage
5
+ # pipeline that eliminates most candidates before calling `valid?`.
6
+ #
7
+ # Stage 1 — Special-character dispatch (O(1)):
8
+ # Strings containing `/`, ` `, or `*@#` route to the only types accepting those chars.
9
+ #
10
+ # Stage 2 — Length lookup (O(1) hash access):
11
+ # Pre-computed table maps each possible length to candidate classes.
12
+ #
13
+ # Stage 3 — Charset pre-filter:
14
+ # Survivors are filtered by their VALID_CHARS_REGEX before calling `valid?`.
15
+ #
16
+ # Typical result: 1-2 `valid?` calls instead of 13.
17
+ #
18
+ # @api private
19
+ class Detector
20
+ # @param identifier_list [Array<Class>] registered identifier classes
21
+ def initialize(identifier_list)
22
+ @classes = identifier_list.dup.freeze
23
+ precompute
24
+ end
25
+
26
+ # Detects all matching identifier types for the given string.
27
+ #
28
+ # @param str [String, nil] the identifier string to detect
29
+ # @return [Array<Symbol>] matching type symbols sorted by specificity
30
+ def call(str)
31
+ input = str.to_s.strip
32
+ return [] if input.empty?
33
+
34
+ upcased = input.upcase
35
+ candidates = filter_candidates(upcased)
36
+ validate_and_sort(input, candidates)
37
+ end
38
+
39
+ private
40
+
41
+ # Runs stages 1-3 to narrow candidate classes.
42
+ #
43
+ # @param upcased [String]
44
+ # @return [Array<Class>]
45
+ def filter_candidates(upcased)
46
+ candidates = stage1_special_chars(upcased) || stage2_length(upcased.length)
47
+ return candidates if candidates.empty?
48
+
49
+ stage3_charset(upcased, candidates)
50
+ end
51
+
52
+ # Validates candidates and returns sorted symbol keys.
53
+ #
54
+ # @param input [String]
55
+ # @param candidates [Array<Class>]
56
+ # @return [Array<Symbol>]
57
+ def validate_and_sort(input, candidates)
58
+ matches = candidates.select { |klass| klass.valid?(input) }
59
+ matches.sort_by! { |klass| @priority_for[klass] }
60
+ matches.map! { |klass| @key_for[klass] }
61
+ end
62
+
63
+ # @return [void]
64
+ def precompute
65
+ build_discriminator_sets
66
+ build_length_table
67
+ build_priority_table
68
+ build_key_table
69
+ end
70
+
71
+ # Classifies types by which special characters their VALID_CHARS_REGEX accepts.
72
+ #
73
+ # @return [void]
74
+ def build_discriminator_sets
75
+ @slash_types = @classes.select { |k| accepts_char?(k, '/') }
76
+ space_types = @classes.select { |k| accepts_char?(k, ' ') }
77
+ @space_only_types = space_types - @slash_types
78
+ @special_types = @classes.select { |k| accepts_char?(k, '*') }
79
+ end
80
+
81
+ # Builds a Hash mapping each possible length to the classes that accept it.
82
+ #
83
+ # @return [void]
84
+ def build_length_table
85
+ @candidates_by_length = Hash.new { |h, k| h[k] = [] }
86
+ @classes.each do |klass|
87
+ id_length = klass::ID_LENGTH
88
+ lengths = id_length.is_a?(Range) ? id_length : [id_length]
89
+ lengths.each { |len| @candidates_by_length[len] << klass }
90
+ end
91
+ @candidates_by_length.each_value(&:freeze)
92
+ end
93
+
94
+ # Builds composite sort keys: check-digit types first, then smaller range, then load order.
95
+ #
96
+ # @return [void]
97
+ def build_priority_table
98
+ @priority_for = {}
99
+ @classes.each_with_index do |klass, index|
100
+ check_digit_rank = klass.has_check_digit? ? 0 : 1
101
+ id_length = klass::ID_LENGTH
102
+ range_size = id_length.is_a?(Range) ? id_length.size : 1
103
+ @priority_for[klass] = [check_digit_rank, range_size, index].freeze
104
+ end
105
+ @priority_for.freeze
106
+ end
107
+
108
+ # Maps each class to its registry symbol key.
109
+ #
110
+ # @return [void]
111
+ def build_key_table
112
+ @key_for = {}
113
+ @classes.each { |klass| @key_for[klass] = klass.short_name.downcase.to_sym }
114
+ @key_for.freeze
115
+ end
116
+
117
+ # Stage 1: route strings with special characters to the only types that accept them.
118
+ # Returns nil if no special chars found (fall through to stage 2).
119
+ #
120
+ # @param upcased [String]
121
+ # @return [Array<Class>, nil]
122
+ def stage1_special_chars(upcased)
123
+ return @slash_types if upcased.include?('/')
124
+ return @space_only_types if upcased.include?(' ')
125
+ return @special_types if upcased.match?(/[*@#]/)
126
+
127
+ nil
128
+ end
129
+
130
+ # Stage 2: look up candidates by string length.
131
+ #
132
+ # @param length [Integer]
133
+ # @return [Array<Class>]
134
+ def stage2_length(length)
135
+ @candidates_by_length[length] || []
136
+ end
137
+
138
+ # Stage 3: filter candidates by character set.
139
+ #
140
+ # @param upcased [String]
141
+ # @param candidates [Array<Class>]
142
+ # @return [Array<Class>]
143
+ def stage3_charset(upcased, candidates)
144
+ candidates.select { |klass| upcased.match?(klass::VALID_CHARS_REGEX) }
145
+ end
146
+
147
+ # Tests whether a class's VALID_CHARS_REGEX accepts a given character.
148
+ #
149
+ # @param klass [Class]
150
+ # @param char [String] single character
151
+ # @return [Boolean]
152
+ def accepts_char?(klass, char)
153
+ char.match?(klass::VALID_CHARS_REGEX)
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SecID
4
+ # Immutable value object representing validation errors for an identifier.
5
+ # Follows Rails/ActiveModel conventions: use {#details} for structured error data
6
+ # and {#messages} for human-readable strings.
7
+ #
8
+ # @example No errors
9
+ # errors = SecID::Errors.new([])
10
+ # errors.none? #=> true
11
+ # errors.empty? #=> true
12
+ # errors.messages #=> []
13
+ #
14
+ # @example With errors
15
+ # err = [{ error: :invalid_length, message: "Expected 12 characters, got 5" }]
16
+ # errors = SecID::Errors.new(err)
17
+ # errors.none? #=> false
18
+ # errors.details #=> [{ error: :invalid_length, message: "..." }]
19
+ # errors.messages #=> ["Expected 12 characters, got 5"]
20
+ class Errors
21
+ # @return [Array<Hash{Symbol => Symbol, String}>] array of error hashes with :error and :message keys
22
+ attr_reader :details
23
+
24
+ # @param errors [Array<Hash{Symbol => Symbol, String}>] array of error hashes
25
+ def initialize(errors)
26
+ @details = errors.freeze
27
+ freeze
28
+ end
29
+
30
+ # @return [Array<String>] human-readable error messages
31
+ def messages
32
+ @details.map { |e| e[:message] }
33
+ end
34
+
35
+ # @return [Boolean] true when there are errors
36
+ def any?
37
+ !@details.empty?
38
+ end
39
+
40
+ # @return [Boolean] true when there are no errors
41
+ def empty?
42
+ @details.empty?
43
+ end
44
+
45
+ # @!method none?
46
+ # @return [Boolean] true when there are no errors
47
+ alias none? empty?
48
+
49
+ # @return [Integer] number of errors
50
+ def size
51
+ @details.size
52
+ end
53
+
54
+ # Yields each error detail hash to the block.
55
+ #
56
+ # @yieldparam detail [Hash{Symbol => Symbol, String}]
57
+ # @return [Enumerator, self]
58
+ def each(&)
59
+ @details.each(&)
60
+ end
61
+
62
+ # @return [Array<String>] alias for {#messages}
63
+ def to_a
64
+ messages
65
+ end
66
+ end
67
+ end
data/lib/sec_id/figi.rb CHANGED
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'set'
4
-
5
- module SecId
3
+ module SecID
6
4
  # Financial Instrument Global Identifier (FIGI) - a 12-character alphanumeric code
7
5
  # that uniquely identifies financial instruments.
8
6
  #
@@ -12,13 +10,18 @@ module SecId
12
10
  # @see https://en.wikipedia.org/wiki/Financial_Instrument_Global_Identifier
13
11
  #
14
12
  # @example Validate a FIGI
15
- # SecId::FIGI.valid?('BBG000BLNQ16') #=> true
13
+ # SecID::FIGI.valid?('BBG000BLNQ16') #=> true
16
14
  #
17
15
  # @example Restore check digit
18
- # SecId::FIGI.restore!('BBG000BLNQ1') #=> 'BBG000BLNQ16'
16
+ # SecID::FIGI.restore!('BBG000BLNQ1') #=> #<SecID::FIGI>
19
17
  class FIGI < Base
20
18
  include Checkable
21
19
 
20
+ FULL_NAME = 'Financial Instrument Global Identifier'
21
+ ID_LENGTH = 12
22
+ EXAMPLE = 'BBG000BLNNH6'
23
+ VALID_CHARS_REGEX = /\A[B-DF-HJ-NP-TV-Z0-9]+\z/
24
+
22
25
  # Regular expression for parsing FIGI components.
23
26
  # The third character must be 'G'. Excludes vowels from valid characters.
24
27
  ID_REGEX = /\A
@@ -47,16 +50,33 @@ module SecId
47
50
  @check_digit = figi_parts[:check_digit]&.to_i
48
51
  end
49
52
 
50
- # @return [Boolean]
51
- def valid_format?
52
- !identifier.nil? && !RESTRICTED_PREFIXES.include?(prefix)
53
- end
54
-
55
53
  # @return [Integer] the calculated check digit (0-9)
56
54
  # @raise [InvalidFormatError] if the FIGI format is invalid
57
55
  def calculate_check_digit
58
56
  validate_format_for_calculation!
59
57
  mod10(luhn_sum_indexed(reversed_digits_single(identifier)))
60
58
  end
59
+
60
+ private
61
+
62
+ # @return [Boolean]
63
+ def valid_format?
64
+ !identifier.nil? && !RESTRICTED_PREFIXES.include?(prefix)
65
+ end
66
+
67
+ # @return [Array<Symbol>]
68
+ def detect_errors
69
+ return [:invalid_prefix] if identifier && RESTRICTED_PREFIXES.include?(prefix)
70
+
71
+ super
72
+ end
73
+
74
+ # @param code [Symbol]
75
+ # @return [String]
76
+ def validation_message(code)
77
+ return "Prefix '#{prefix}' is restricted" if code == :invalid_prefix
78
+
79
+ super
80
+ end
61
81
  end
62
82
  end
data/lib/sec_id/fisn.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module SecId
3
+ module SecID
4
4
  # Financial Instrument Short Name (FISN) - a human-readable short name for financial
5
5
  # instruments per ISO 18774.
6
6
  #
@@ -13,14 +13,20 @@ module SecId
13
13
  # @see https://en.wikipedia.org/wiki/ISO_18774
14
14
  #
15
15
  # @example Validate a FISN
16
- # SecId::FISN.valid?('APPLE INC/SH') #=> true
17
- # SecId::FISN.valid?('apple inc/sh') #=> true (normalized to uppercase)
16
+ # SecID::FISN.valid?('APPLE INC/SH') #=> true
17
+ # SecID::FISN.valid?('apple inc/sh') #=> true (normalized to uppercase)
18
18
  #
19
19
  # @example Access FISN components
20
- # fisn = SecId::FISN.new('APPLE INC/SH')
20
+ # fisn = SecID::FISN.new('APPLE INC/SH')
21
21
  # fisn.issuer #=> 'APPLE INC'
22
22
  # fisn.description #=> 'SH'
23
23
  class FISN < Base
24
+ FULL_NAME = 'Financial Instrument Short Name'
25
+ ID_LENGTH = (3..35)
26
+ EXAMPLE = 'APPLE INC/SH'
27
+ VALID_CHARS_REGEX = %r{\A[A-Z0-9 /]+\z}
28
+ SEPARATORS = /-/
29
+
24
30
  # Regular expression for parsing FISN components.
25
31
  # Issuer: 1-15 chars, Description: 1-19 chars, Total: max 35 chars
26
32
  ID_REGEX = %r{\A
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module SecId
4
- # Country-specific BBAN validation rules for IBAN
3
+ module SecID
4
+ # Country-specific BBAN validation rules for IBAN.
5
+ #
6
+ # @api private
5
7
  # rubocop:disable Metrics/ModuleLength
6
8
  module IBANCountryRules
7
9
  # Country-specific BBAN rules for EU/EEA countries
data/lib/sec_id/iban.rb CHANGED
@@ -2,25 +2,30 @@
2
2
 
3
3
  require_relative 'iban/country_rules'
4
4
 
5
- module SecId
5
+ module SecID
6
6
  # International Bank Account Number (IBAN) - an international standard for identifying
7
7
  # bank accounts across national borders (ISO 13616).
8
8
  #
9
9
  # Format: 2-letter country code + 2-digit check digits + BBAN (Basic Bank Account Number, 11-30 chars)
10
- # Note: Unlike other SecId identifiers, the check digits are in positions 3-4, not at the end.
10
+ # Note: Unlike other SecID identifiers, the check digits are in positions 3-4, not at the end.
11
11
  #
12
12
  # @see https://en.wikipedia.org/wiki/International_Bank_Account_Number
13
13
  # @see https://www.iban.com/structure
14
14
  #
15
15
  # @example Validate an IBAN
16
- # SecId::IBAN.valid?('DE89370400440532013000') #=> true
16
+ # SecID::IBAN.valid?('DE89370400440532013000') #=> true
17
17
  #
18
18
  # @example Restore check digits
19
- # SecId::IBAN.restore!('DE00370400440532013000') #=> 'DE89370400440532013000'
19
+ # SecID::IBAN.restore!('DE00370400440532013000') #=> #<SecID::IBAN>
20
20
  class IBAN < Base
21
21
  include Checkable
22
22
  include IBANCountryRules
23
23
 
24
+ FULL_NAME = 'International Bank Account Number'
25
+ ID_LENGTH = (15..34)
26
+ EXAMPLE = 'GB29NWBK60161331926819'
27
+ VALID_CHARS_REGEX = /\A[A-Z0-9]+\z/
28
+
24
29
  # Regular expression for parsing IBAN components.
25
30
  # Note: Check digit positioning is handled in initialize, not in the regex.
26
31
  ID_REGEX = /\A
@@ -60,6 +65,13 @@ module SecId
60
65
  extract_bban_components if valid_format?
61
66
  end
62
67
 
68
+ # @return [String]
69
+ # @raise [InvalidFormatError] if the IBAN format is invalid
70
+ def restore
71
+ cd = calculate_check_digit
72
+ "#{country_code}#{cd.to_s.rjust(2, '0')}#{bban}"
73
+ end
74
+
63
75
  # @return [Integer] the calculated 2-digit check value (1-98)
64
76
  # @raise [InvalidFormatError] if the IBAN format is invalid
65
77
  def calculate_check_digit
@@ -67,13 +79,6 @@ module SecId
67
79
  mod97(numeric_string_for_check)
68
80
  end
69
81
 
70
- # @return [Boolean]
71
- def valid_format?
72
- return false unless identifier
73
-
74
- valid_bban_format?
75
- end
76
-
77
82
  # @return [Boolean]
78
83
  def valid_bban_format?
79
84
  return false unless bban
@@ -96,13 +101,40 @@ module SecId
96
101
 
97
102
  # @return [String]
98
103
  def to_s
99
- return full_number unless check_digit
104
+ return full_id unless check_digit
100
105
 
101
106
  "#{country_code}#{check_digit.to_s.rjust(2, '0')}#{bban}"
102
107
  end
103
108
 
104
109
  private
105
110
 
111
+ # @return [Integer]
112
+ def check_digit_width
113
+ 2
114
+ end
115
+
116
+ # @return [Boolean]
117
+ def valid_format?
118
+ return false unless identifier
119
+
120
+ valid_bban_format?
121
+ end
122
+
123
+ # @return [Array<Symbol>]
124
+ def detect_errors
125
+ return [:invalid_bban] if identifier && !valid_bban_format?
126
+
127
+ super
128
+ end
129
+
130
+ # @param code [Symbol]
131
+ # @return [String]
132
+ def validation_message(code)
133
+ return "BBAN format is invalid for country '#{country_code}'" if code == :invalid_bban
134
+
135
+ super
136
+ end
137
+
106
138
  # @param rest [String] the IBAN string after country code
107
139
  # @return [void]
108
140
  def extract_check_digit_and_bban(rest)
data/lib/sec_id/isin.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module SecId
3
+ module SecID
4
4
  # International Securities Identification Number (ISIN) - a 12-character alphanumeric code
5
5
  # that uniquely identifies a security globally.
6
6
  #
@@ -9,13 +9,18 @@ module SecId
9
9
  # @see https://en.wikipedia.org/wiki/International_Securities_Identification_Number
10
10
  #
11
11
  # @example Validate an ISIN
12
- # SecId::ISIN.valid?('US5949181045') #=> true
12
+ # SecID::ISIN.valid?('US5949181045') #=> true
13
13
  #
14
14
  # @example Restore check digit
15
- # SecId::ISIN.restore!('US594918104') #=> 'US5949181045'
15
+ # SecID::ISIN.restore!('US594918104') #=> #<SecID::ISIN>
16
16
  class ISIN < Base
17
17
  include Checkable
18
18
 
19
+ FULL_NAME = 'International Securities Identification Number'
20
+ ID_LENGTH = 12
21
+ EXAMPLE = 'US5949181045'
22
+ VALID_CHARS_REGEX = /\A[A-Z0-9]+\z/
23
+
19
24
  # Regular expression for parsing ISIN components.
20
25
  ID_REGEX = /\A
21
26
  (?<identifier>
@@ -148,9 +153,9 @@ module SecId
148
153
 
149
154
  case nsin_type
150
155
  when :cusip then to_cusip
151
- when :sedol then SEDOL.new(nsin[2..])
152
- when :wkn then WKN.new(nsin[3..])
153
- when :valoren then Valoren.new(nsin)
156
+ when :sedol then to_sedol
157
+ when :wkn then to_wkn
158
+ when :valoren then to_valoren
154
159
  else nsin # :generic - return raw string
155
160
  end
156
161
  end
data/lib/sec_id/lei.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module SecId
3
+ module SecID
4
4
  # Legal Entity Identifier (LEI) - a 20-character alphanumeric code that
5
5
  # uniquely identifies legal entities participating in financial transactions.
6
6
  #
@@ -10,13 +10,18 @@ module SecId
10
10
  # @see https://www.gleif.org/en/about-lei/iso-17442-the-lei-code-structure
11
11
  #
12
12
  # @example Validate a LEI
13
- # SecId::LEI.valid?('529900T8BM49AURSDO55') #=> true
13
+ # SecID::LEI.valid?('529900T8BM49AURSDO55') #=> true
14
14
  #
15
15
  # @example Calculate check digit
16
- # SecId::LEI.check_digit('529900T8BM49AURSDO') #=> 55
16
+ # SecID::LEI.check_digit('529900T8BM49AURSDO') #=> 55
17
17
  class LEI < Base
18
18
  include Checkable
19
19
 
20
+ FULL_NAME = 'Legal Entity Identifier'
21
+ ID_LENGTH = 20
22
+ EXAMPLE = '7LTWFZYICNSX8D621K86'
23
+ VALID_CHARS_REGEX = /\A[0-9A-Z]+\z/
24
+
20
25
  # Regular expression for parsing LEI components.
21
26
  ID_REGEX = /\A
22
27
  (?<identifier>
@@ -52,15 +57,13 @@ module SecId
52
57
  mod97("#{numeric_identifier}00")
53
58
  end
54
59
 
55
- # @return [String]
56
- def to_s
57
- return full_number unless check_digit
60
+ private
58
61
 
59
- "#{identifier}#{check_digit.to_s.rjust(2, '0')}"
62
+ # @return [Integer]
63
+ def check_digit_width
64
+ 2
60
65
  end
61
66
 
62
- private
63
-
64
67
  # @return [String] the numeric string representation
65
68
  def numeric_identifier
66
69
  identifier.each_char.map { |char| CHAR_TO_DIGIT.fetch(char) }.join