sec_id 4.4.1 → 5.1.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 +59 -3
- data/MIGRATION.md +257 -0
- data/README.md +286 -148
- data/lib/sec_id/base.rb +43 -29
- data/lib/sec_id/cei.rb +14 -2
- data/lib/sec_id/cfi.rb +41 -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 +60 -16
- data/lib/sec_id/concerns/validatable.rb +158 -0
- data/lib/sec_id/cusip.rb +24 -8
- data/lib/sec_id/detector.rb +156 -0
- data/lib/sec_id/errors.rb +67 -0
- data/lib/sec_id/figi.rb +38 -8
- data/lib/sec_id/fisn.rb +15 -4
- data/lib/sec_id/iban/country_rules.rb +4 -2
- data/lib/sec_id/iban.rb +59 -12
- data/lib/sec_id/isin.rb +25 -6
- data/lib/sec_id/lei.rb +22 -9
- data/lib/sec_id/occ.rb +50 -25
- data/lib/sec_id/sedol.rb +12 -7
- data/lib/sec_id/valoren.rb +29 -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
|
@@ -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
|
data/lib/sec_id/cusip.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
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
|
-
#
|
|
12
|
+
# SecID::CUSIP.valid?('037833100') #=> true
|
|
13
13
|
#
|
|
14
14
|
# @example Convert to ISIN
|
|
15
|
-
# cusip =
|
|
16
|
-
# cusip.to_isin('US') #=> #<
|
|
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>
|
|
@@ -40,6 +45,13 @@ module SecId
|
|
|
40
45
|
@check_digit = cusip_parts[:check_digit]&.to_i
|
|
41
46
|
end
|
|
42
47
|
|
|
48
|
+
# @return [String, nil]
|
|
49
|
+
def to_pretty_s
|
|
50
|
+
return nil unless valid?
|
|
51
|
+
|
|
52
|
+
"#{cusip6} #{issue} #{check_digit}"
|
|
53
|
+
end
|
|
54
|
+
|
|
43
55
|
# @return [Integer] the calculated check digit (0-9)
|
|
44
56
|
# @raise [InvalidFormatError] if the CUSIP format is invalid
|
|
45
57
|
def calculate_check_digit
|
|
@@ -55,12 +67,16 @@ module SecId
|
|
|
55
67
|
raise(InvalidFormatError, "'#{country_code}' is not a CGS country code!")
|
|
56
68
|
end
|
|
57
69
|
|
|
58
|
-
|
|
59
|
-
isin = ISIN.new(country_code + cusip_with_check_digit)
|
|
60
|
-
isin.restore!
|
|
61
|
-
isin
|
|
70
|
+
ISIN.new(country_code + restore).restore!
|
|
62
71
|
end
|
|
63
72
|
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# @return [Hash]
|
|
76
|
+
def components = { cusip6:, issue:, check_digit: }
|
|
77
|
+
|
|
78
|
+
public
|
|
79
|
+
|
|
64
80
|
# @return [Boolean] true if first character is a letter (CINS identifier)
|
|
65
81
|
def cins?
|
|
66
82
|
cusip6[0] < '0' || cusip6[0] > '9'
|
|
@@ -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
|
-
|
|
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
|
-
#
|
|
13
|
+
# SecID::FIGI.valid?('BBG000BLNQ16') #=> true
|
|
16
14
|
#
|
|
17
15
|
# @example Restore check digit
|
|
18
|
-
#
|
|
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,9 +50,11 @@ module SecId
|
|
|
47
50
|
@check_digit = figi_parts[:check_digit]&.to_i
|
|
48
51
|
end
|
|
49
52
|
|
|
50
|
-
# @return [
|
|
51
|
-
def
|
|
52
|
-
|
|
53
|
+
# @return [String, nil]
|
|
54
|
+
def to_pretty_s
|
|
55
|
+
return nil unless valid?
|
|
56
|
+
|
|
57
|
+
"#{prefix}G #{random_part} #{check_digit}"
|
|
53
58
|
end
|
|
54
59
|
|
|
55
60
|
# @return [Integer] the calculated check digit (0-9)
|
|
@@ -58,5 +63,30 @@ module SecId
|
|
|
58
63
|
validate_format_for_calculation!
|
|
59
64
|
mod10(luhn_sum_indexed(reversed_digits_single(identifier)))
|
|
60
65
|
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# @return [Hash]
|
|
70
|
+
def components = { prefix:, random_part:, check_digit: }
|
|
71
|
+
|
|
72
|
+
# @return [Boolean]
|
|
73
|
+
def valid_format?
|
|
74
|
+
!identifier.nil? && !RESTRICTED_PREFIXES.include?(prefix)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @return [Array<Symbol>]
|
|
78
|
+
def detect_errors
|
|
79
|
+
return [:invalid_prefix] if identifier && RESTRICTED_PREFIXES.include?(prefix)
|
|
80
|
+
|
|
81
|
+
super
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @param code [Symbol]
|
|
85
|
+
# @return [String]
|
|
86
|
+
def validation_message(code)
|
|
87
|
+
return "Prefix '#{prefix}' is restricted" if code == :invalid_prefix
|
|
88
|
+
|
|
89
|
+
super
|
|
90
|
+
end
|
|
61
91
|
end
|
|
62
92
|
end
|
data/lib/sec_id/fisn.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
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
|
-
#
|
|
17
|
-
#
|
|
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 =
|
|
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
|
|
@@ -48,5 +54,10 @@ module SecId
|
|
|
48
54
|
def to_s
|
|
49
55
|
identifier.to_s
|
|
50
56
|
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# @return [Hash]
|
|
61
|
+
def components = { issuer:, description: }
|
|
51
62
|
end
|
|
52
63
|
end
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
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
|
|
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
|
|
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
|
-
#
|
|
16
|
+
# SecID::IBAN.valid?('DE89370400440532013000') #=> true
|
|
17
17
|
#
|
|
18
18
|
# @example Restore check digits
|
|
19
|
-
#
|
|
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,55 @@ module SecId
|
|
|
96
101
|
|
|
97
102
|
# @return [String]
|
|
98
103
|
def to_s
|
|
99
|
-
return
|
|
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
|
|
|
109
|
+
# @return [String, nil]
|
|
110
|
+
def to_pretty_s
|
|
111
|
+
to_s.scan(/.{1,4}/).join(' ') if valid?
|
|
112
|
+
end
|
|
113
|
+
|
|
104
114
|
private
|
|
105
115
|
|
|
116
|
+
# @return [Hash]
|
|
117
|
+
def components
|
|
118
|
+
hash = { country_code:, bban:, check_digit: }
|
|
119
|
+
hash[:bank_code] = bank_code if bank_code
|
|
120
|
+
hash[:branch_code] = branch_code if branch_code
|
|
121
|
+
hash[:account_number] = account_number if account_number
|
|
122
|
+
hash[:national_check] = national_check if national_check
|
|
123
|
+
hash
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# @return [Integer]
|
|
127
|
+
def check_digit_width
|
|
128
|
+
2
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @return [Boolean]
|
|
132
|
+
def valid_format?
|
|
133
|
+
return false unless identifier
|
|
134
|
+
|
|
135
|
+
valid_bban_format?
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# @return [Array<Symbol>]
|
|
139
|
+
def detect_errors
|
|
140
|
+
return [:invalid_bban] if identifier && !valid_bban_format?
|
|
141
|
+
|
|
142
|
+
super
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @param code [Symbol]
|
|
146
|
+
# @return [String]
|
|
147
|
+
def validation_message(code)
|
|
148
|
+
return "BBAN format is invalid for country '#{country_code}'" if code == :invalid_bban
|
|
149
|
+
|
|
150
|
+
super
|
|
151
|
+
end
|
|
152
|
+
|
|
106
153
|
# @param rest [String] the IBAN string after country code
|
|
107
154
|
# @return [void]
|
|
108
155
|
def extract_check_digit_and_bban(rest)
|