sec_id 6.1.0 → 7.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 +14 -0
- data/MIGRATION.md +147 -0
- data/README.md +71 -44
- data/lib/sec_id/base.rb +36 -17
- data/lib/sec_id/cei.rb +7 -7
- data/lib/sec_id/cik.rb +1 -1
- data/lib/sec_id/concerns/checkable.rb +90 -46
- data/lib/sec_id/concerns/generatable.rb +5 -5
- data/lib/sec_id/concerns/normalizable.rb +4 -4
- data/lib/sec_id/concerns/validatable.rb +7 -7
- data/lib/sec_id/cusip.rb +8 -8
- data/lib/sec_id/deprecation.rb +26 -0
- data/lib/sec_id/dti.rb +8 -14
- data/lib/sec_id/figi.rb +9 -9
- data/lib/sec_id/iban.rb +20 -20
- data/lib/sec_id/isin.rb +9 -9
- data/lib/sec_id/lei.rb +10 -10
- data/lib/sec_id/occ.rb +1 -1
- data/lib/sec_id/sedol.rb +11 -11
- data/lib/sec_id/upi.rb +76 -0
- data/lib/sec_id/valoren.rb +2 -2
- data/lib/sec_id/version.rb +1 -1
- data/lib/sec_id/wkn.rb +1 -1
- data/lib/sec_id.rb +9 -3
- data/sec_id.gemspec +2 -2
- data/sig/sec_id/base.rbs +9 -4
- data/sig/sec_id/cei.rbs +4 -0
- data/sig/sec_id/concerns/checkable.rbs +17 -7
- data/sig/sec_id/concerns/generatable.rbs +1 -1
- data/sig/sec_id/concerns/validatable.rbs +1 -1
- data/sig/sec_id/cusip.rbs +4 -0
- data/sig/sec_id/deprecation.rbs +6 -0
- data/sig/sec_id/dti.rbs +6 -1
- data/sig/sec_id/figi.rbs +4 -0
- data/sig/sec_id/iban.rbs +7 -3
- data/sig/sec_id/isin.rbs +4 -0
- data/sig/sec_id/lei.rbs +5 -1
- data/sig/sec_id/sedol.rbs +4 -0
- data/sig/sec_id/upi.rbs +29 -0
- data/sig/sec_id.rbs +5 -2
- metadata +7 -3
data/lib/sec_id/iban.rb
CHANGED
|
@@ -6,8 +6,8 @@ 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
|
-
# Format: 2-letter country code + 2-digit
|
|
10
|
-
# Note: Unlike other SecID identifiers, the
|
|
9
|
+
# Format: 2-letter country code + 2-digit checksum + BBAN (Basic Bank Account Number, 11-30 chars)
|
|
10
|
+
# Note: Unlike other SecID identifiers, the checksum 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
|
|
@@ -15,7 +15,7 @@ module SecID
|
|
|
15
15
|
# @example Validate an IBAN
|
|
16
16
|
# SecID::IBAN.valid?('DE89370400440532013000') #=> true
|
|
17
17
|
#
|
|
18
|
-
# @example Restore
|
|
18
|
+
# @example Restore checksum
|
|
19
19
|
# SecID::IBAN.restore!('DE00370400440532013000') #=> #<SecID::IBAN>
|
|
20
20
|
class IBAN < Base
|
|
21
21
|
include Checkable
|
|
@@ -30,7 +30,7 @@ module SecID
|
|
|
30
30
|
VALID_CHARS_REGEX = /\A[A-Z0-9]+\z/
|
|
31
31
|
|
|
32
32
|
# Regular expression for parsing IBAN components.
|
|
33
|
-
# Note:
|
|
33
|
+
# Note: Checksum positioning is handled in initialize, not in the regex.
|
|
34
34
|
ID_REGEX = /\A
|
|
35
35
|
(?<country_code>[A-Z]{2})
|
|
36
36
|
(?<rest>[A-Z0-9]{13,32})
|
|
@@ -62,7 +62,7 @@ module SecID
|
|
|
62
62
|
# @return [String, nil] the account number (extracted from BBAN if country rules define it)
|
|
63
63
|
attr_reader :account_number
|
|
64
64
|
|
|
65
|
-
# @return [String, nil] the national
|
|
65
|
+
# @return [String, nil] the national checksum (extracted from BBAN if country rules define it)
|
|
66
66
|
attr_reader :national_check
|
|
67
67
|
|
|
68
68
|
# @param iban [String] the IBAN string to parse
|
|
@@ -72,7 +72,7 @@ module SecID
|
|
|
72
72
|
rest = iban_parts[:rest]
|
|
73
73
|
|
|
74
74
|
if @country_code && rest
|
|
75
|
-
|
|
75
|
+
extract_checksum_and_bban(rest)
|
|
76
76
|
@identifier = "#{@country_code}#{@bban}" if @bban
|
|
77
77
|
end
|
|
78
78
|
|
|
@@ -82,19 +82,19 @@ module SecID
|
|
|
82
82
|
# @return [String]
|
|
83
83
|
# @raise [InvalidFormatError] if the IBAN format is invalid
|
|
84
84
|
def restore
|
|
85
|
-
cd =
|
|
85
|
+
cd = calculate_checksum
|
|
86
86
|
"#{country_code}#{cd.to_s.rjust(2, '0')}#{bban}"
|
|
87
87
|
end
|
|
88
88
|
|
|
89
89
|
# @return [Integer] the calculated 2-digit check value (1-98)
|
|
90
90
|
# @raise [InvalidFormatError] if the IBAN format is invalid
|
|
91
|
-
def
|
|
91
|
+
def calculate_checksum
|
|
92
92
|
validate_format_for_calculation!
|
|
93
93
|
mod97(numeric_string_for_check)
|
|
94
94
|
end
|
|
95
95
|
|
|
96
|
-
# Generates a random IBAN body for a numeric-BBAN country with placeholder
|
|
97
|
-
# The default {Generatable::ClassMethods#generate} restores the real
|
|
96
|
+
# Generates a random IBAN body for a numeric-BBAN country with placeholder checksum.
|
|
97
|
+
# The default {Generatable::ClassMethods#generate} restores the real checksum via `restore!`.
|
|
98
98
|
#
|
|
99
99
|
# @param random [Random] source of randomness
|
|
100
100
|
# @return [String] a country code + "00" + numeric BBAN
|
|
@@ -126,9 +126,9 @@ module SecID
|
|
|
126
126
|
|
|
127
127
|
# @return [String]
|
|
128
128
|
def to_s
|
|
129
|
-
return full_id unless
|
|
129
|
+
return full_id unless checksum
|
|
130
130
|
|
|
131
|
-
"#{country_code}#{
|
|
131
|
+
"#{country_code}#{checksum.to_s.rjust(2, '0')}#{bban}"
|
|
132
132
|
end
|
|
133
133
|
|
|
134
134
|
# @return [String, nil]
|
|
@@ -140,7 +140,7 @@ module SecID
|
|
|
140
140
|
|
|
141
141
|
# @return [Hash]
|
|
142
142
|
def components
|
|
143
|
-
hash = { country_code:, bban:,
|
|
143
|
+
hash = { country_code:, bban:, checksum: }
|
|
144
144
|
hash[:bank_code] = bank_code if bank_code
|
|
145
145
|
hash[:branch_code] = branch_code if branch_code
|
|
146
146
|
hash[:account_number] = account_number if account_number
|
|
@@ -149,7 +149,7 @@ module SecID
|
|
|
149
149
|
end
|
|
150
150
|
|
|
151
151
|
# @return [Integer]
|
|
152
|
-
def
|
|
152
|
+
def checksum_width
|
|
153
153
|
2
|
|
154
154
|
end
|
|
155
155
|
|
|
@@ -177,14 +177,14 @@ module SecID
|
|
|
177
177
|
|
|
178
178
|
# @param rest [String] the IBAN string after country code
|
|
179
179
|
# @return [void]
|
|
180
|
-
def
|
|
180
|
+
def extract_checksum_and_bban(rest)
|
|
181
181
|
expected = expected_bban_length_for_country
|
|
182
182
|
|
|
183
|
-
if
|
|
184
|
-
@
|
|
183
|
+
if checksum?(rest, expected)
|
|
184
|
+
@checksum = rest[0, 2].to_i
|
|
185
185
|
@bban = rest[2..]
|
|
186
186
|
else
|
|
187
|
-
@
|
|
187
|
+
@checksum = nil
|
|
188
188
|
@bban = rest
|
|
189
189
|
end
|
|
190
190
|
end
|
|
@@ -197,11 +197,11 @@ module SecID
|
|
|
197
197
|
# @param rest [String] the IBAN string after country code
|
|
198
198
|
# @param expected_bban_length [Integer, nil] the expected BBAN length for the country
|
|
199
199
|
# @return [Boolean]
|
|
200
|
-
def
|
|
200
|
+
def checksum?(rest, expected_bban_length)
|
|
201
201
|
return false unless rest[0, 2].match?(/\A\d{2}\z/)
|
|
202
202
|
return true unless expected_bban_length
|
|
203
203
|
|
|
204
|
-
# If we know expected BBAN length, check if rest matches with or without
|
|
204
|
+
# If we know expected BBAN length, check if rest matches with or without checksum
|
|
205
205
|
rest.length == expected_bban_length + 2 || rest.length != expected_bban_length
|
|
206
206
|
end
|
|
207
207
|
|
data/lib/sec_id/isin.rb
CHANGED
|
@@ -4,14 +4,14 @@ module SecID
|
|
|
4
4
|
# International Securities Identification Number (ISIN) - a 12-character alphanumeric code
|
|
5
5
|
# that uniquely identifies a security globally.
|
|
6
6
|
#
|
|
7
|
-
# Format: 2-letter country code + 9-character NSIN + 1-digit
|
|
7
|
+
# Format: 2-letter country code + 9-character NSIN + 1-digit checksum
|
|
8
8
|
#
|
|
9
9
|
# @see https://en.wikipedia.org/wiki/International_Securities_Identification_Number
|
|
10
10
|
#
|
|
11
11
|
# @example Validate an ISIN
|
|
12
12
|
# SecID::ISIN.valid?('US5949181045') #=> true
|
|
13
13
|
#
|
|
14
|
-
# @example Restore
|
|
14
|
+
# @example Restore checksum
|
|
15
15
|
# SecID::ISIN.restore!('US594918104') #=> #<SecID::ISIN>
|
|
16
16
|
class ISIN < Base
|
|
17
17
|
include Checkable
|
|
@@ -30,7 +30,7 @@ module SecID
|
|
|
30
30
|
(?<identifier>
|
|
31
31
|
(?<country_code>[A-Z]{2})
|
|
32
32
|
(?<nsin>[A-Z0-9]{9}))
|
|
33
|
-
(?<
|
|
33
|
+
(?<checksum>\d)?
|
|
34
34
|
\z/x
|
|
35
35
|
|
|
36
36
|
# Country codes that use CUSIP Global Services (CGS) for NSIN assignment.
|
|
@@ -71,19 +71,19 @@ module SecID
|
|
|
71
71
|
@identifier = isin_parts[:identifier]
|
|
72
72
|
@country_code = isin_parts[:country_code]
|
|
73
73
|
@nsin = isin_parts[:nsin]
|
|
74
|
-
@
|
|
74
|
+
@checksum = isin_parts[:checksum]&.to_i
|
|
75
75
|
end
|
|
76
76
|
|
|
77
77
|
# @return [String, nil]
|
|
78
78
|
def to_pretty_s
|
|
79
79
|
return nil unless valid?
|
|
80
80
|
|
|
81
|
-
"#{country_code} #{nsin} #{
|
|
81
|
+
"#{country_code} #{nsin} #{checksum}"
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
-
# @return [Integer] the calculated
|
|
84
|
+
# @return [Integer] the calculated checksum (0-9)
|
|
85
85
|
# @raise [InvalidFormatError] if the ISIN format is invalid
|
|
86
|
-
def
|
|
86
|
+
def calculate_checksum
|
|
87
87
|
validate_format_for_calculation!
|
|
88
88
|
mod10(luhn_sum_standard(reversed_digits_multi(identifier)))
|
|
89
89
|
end
|
|
@@ -91,7 +91,7 @@ module SecID
|
|
|
91
91
|
# Generates a random ISIN body: 2-letter country code + 9-character NSIN.
|
|
92
92
|
#
|
|
93
93
|
# @param random [Random] source of randomness
|
|
94
|
-
# @return [String] an 11-character ISIN body without
|
|
94
|
+
# @return [String] an 11-character ISIN body without checksum
|
|
95
95
|
def self.generate_body(random)
|
|
96
96
|
"#{random_string(ALPHA, 2, random: random)}#{random_string(ALPHANUMERIC, 9, random: random)}"
|
|
97
97
|
end
|
|
@@ -177,6 +177,6 @@ module SecID
|
|
|
177
177
|
private
|
|
178
178
|
|
|
179
179
|
# @return [Hash]
|
|
180
|
-
def components = { country_code:, nsin:,
|
|
180
|
+
def components = { country_code:, nsin:, checksum: }
|
|
181
181
|
end
|
|
182
182
|
end
|
data/lib/sec_id/lei.rb
CHANGED
|
@@ -4,7 +4,7 @@ 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
|
#
|
|
7
|
-
# Format: 4-character LOU ID + 2-character reserved + 12-character entity ID + 2-digit
|
|
7
|
+
# Format: 4-character LOU ID + 2-character reserved + 12-character entity ID + 2-digit checksum
|
|
8
8
|
#
|
|
9
9
|
# @see https://en.wikipedia.org/wiki/Legal_Entity_Identifier
|
|
10
10
|
# @see https://www.gleif.org/en/about-lei/iso-17442-the-lei-code-structure
|
|
@@ -12,8 +12,8 @@ module SecID
|
|
|
12
12
|
# @example Validate a LEI
|
|
13
13
|
# SecID::LEI.valid?('529900T8BM49AURSDO55') #=> true
|
|
14
14
|
#
|
|
15
|
-
# @example Calculate
|
|
16
|
-
# SecID::LEI.
|
|
15
|
+
# @example Calculate checksum
|
|
16
|
+
# SecID::LEI.checksum('529900T8BM49AURSDO') #=> 55
|
|
17
17
|
class LEI < Base
|
|
18
18
|
include Checkable
|
|
19
19
|
|
|
@@ -32,7 +32,7 @@ module SecID
|
|
|
32
32
|
(?<lou_id>[0-9A-Z]{4})
|
|
33
33
|
(?<reserved>[0-9A-Z]{2})
|
|
34
34
|
(?<entity_id>[0-9A-Z]{12}))
|
|
35
|
-
(?<
|
|
35
|
+
(?<checksum>\d{2})?
|
|
36
36
|
\z/x
|
|
37
37
|
|
|
38
38
|
# @return [String, nil] the 4-character Local Operating Unit (LOU) identifier
|
|
@@ -51,7 +51,7 @@ module SecID
|
|
|
51
51
|
@lou_id = lei_parts[:lou_id]
|
|
52
52
|
@reserved = lei_parts[:reserved]
|
|
53
53
|
@entity_id = lei_parts[:entity_id]
|
|
54
|
-
@
|
|
54
|
+
@checksum = lei_parts[:checksum]&.to_i
|
|
55
55
|
end
|
|
56
56
|
|
|
57
57
|
# @return [String, nil]
|
|
@@ -61,9 +61,9 @@ module SecID
|
|
|
61
61
|
to_s.scan(/.{1,4}/).join(' ')
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
-
# @return [Integer] the calculated 2-digit
|
|
64
|
+
# @return [Integer] the calculated 2-digit checksum (1-98)
|
|
65
65
|
# @raise [InvalidFormatError] if the LEI format is invalid
|
|
66
|
-
def
|
|
66
|
+
def calculate_checksum
|
|
67
67
|
validate_format_for_calculation!
|
|
68
68
|
mod97("#{numeric_identifier}00")
|
|
69
69
|
end
|
|
@@ -71,7 +71,7 @@ module SecID
|
|
|
71
71
|
# Generates a random LEI body: 18 alphanumeric characters.
|
|
72
72
|
#
|
|
73
73
|
# @param random [Random] source of randomness
|
|
74
|
-
# @return [String] an 18-character LEI body without
|
|
74
|
+
# @return [String] an 18-character LEI body without checksum
|
|
75
75
|
def self.generate_body(random)
|
|
76
76
|
random_string(ALPHANUMERIC, 18, random: random)
|
|
77
77
|
end
|
|
@@ -80,10 +80,10 @@ module SecID
|
|
|
80
80
|
private
|
|
81
81
|
|
|
82
82
|
# @return [Hash]
|
|
83
|
-
def components = { lou_id:, reserved:, entity_id:,
|
|
83
|
+
def components = { lou_id:, reserved:, entity_id:, checksum: }
|
|
84
84
|
|
|
85
85
|
# @return [Integer]
|
|
86
|
-
def
|
|
86
|
+
def checksum_width
|
|
87
87
|
2
|
|
88
88
|
end
|
|
89
89
|
|
data/lib/sec_id/occ.rb
CHANGED
|
@@ -6,7 +6,7 @@ module SecID
|
|
|
6
6
|
# OCC Option Symbol - standardized option symbol format used by Option Clearing Corporation.
|
|
7
7
|
# Format: 6-char underlying (padded) + 6-char date (YYMMDD) + type (C/P) + 8-digit strike (in mills).
|
|
8
8
|
#
|
|
9
|
-
# @note OCC identifiers have no
|
|
9
|
+
# @note OCC identifiers have no checksum and validation includes both format
|
|
10
10
|
# and date parseability checks.
|
|
11
11
|
#
|
|
12
12
|
# @see https://en.wikipedia.org/wiki/Option_symbol#The_OCC_Option_Symbol
|
data/lib/sec_id/sedol.rb
CHANGED
|
@@ -4,7 +4,7 @@ module SecID
|
|
|
4
4
|
# Stock Exchange Daily Official List (SEDOL) - a 7-character alphanumeric code
|
|
5
5
|
# that identifies securities traded on the London Stock Exchange and other UK exchanges.
|
|
6
6
|
#
|
|
7
|
-
# Format: 6-character identifier + 1-digit
|
|
7
|
+
# Format: 6-character identifier + 1-digit checksum
|
|
8
8
|
# Note: SEDOL excludes vowels (A, E, I, O, U) to avoid forming words.
|
|
9
9
|
#
|
|
10
10
|
# @see https://en.wikipedia.org/wiki/SEDOL
|
|
@@ -12,8 +12,8 @@ module SecID
|
|
|
12
12
|
# @example Validate a SEDOL
|
|
13
13
|
# SecID::SEDOL.valid?('B19GKT4') #=> true
|
|
14
14
|
#
|
|
15
|
-
# @example Calculate
|
|
16
|
-
# SecID::SEDOL.
|
|
15
|
+
# @example Calculate checksum
|
|
16
|
+
# SecID::SEDOL.checksum('B19GKT') #=> 4
|
|
17
17
|
class SEDOL < Base
|
|
18
18
|
include Checkable
|
|
19
19
|
|
|
@@ -30,10 +30,10 @@ module SecID
|
|
|
30
30
|
# Excludes vowels (A, E, I, O, U) from valid characters.
|
|
31
31
|
ID_REGEX = /\A
|
|
32
32
|
(?<identifier>[0-9BCDFGHJKLMNPQRSTVWXYZ]{6})
|
|
33
|
-
(?<
|
|
33
|
+
(?<checksum>\d)?
|
|
34
34
|
\z/x
|
|
35
35
|
|
|
36
|
-
# Weights applied to each character position in the
|
|
36
|
+
# Weights applied to each character position in the checksum calculation.
|
|
37
37
|
CHARACTER_WEIGHTS = [1, 3, 1, 7, 3, 9].freeze
|
|
38
38
|
|
|
39
39
|
# Characters valid in a SEDOL body (alphanumeric excluding vowels).
|
|
@@ -43,14 +43,14 @@ module SecID
|
|
|
43
43
|
def initialize(sedol)
|
|
44
44
|
sedol_parts = parse sedol
|
|
45
45
|
@identifier = sedol_parts[:identifier]
|
|
46
|
-
@
|
|
46
|
+
@checksum = sedol_parts[:checksum]&.to_i
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
# Valid country codes for SEDOL to ISIN conversion.
|
|
50
50
|
ISIN_COUNTRY_CODES = Set.new(%w[GB IE GG IM JE FK]).freeze
|
|
51
51
|
|
|
52
52
|
# @param country_code [String] the ISO 3166-1 alpha-2 country code (default: 'GB')
|
|
53
|
-
# @return [ISIN] a new ISIN instance with calculated
|
|
53
|
+
# @return [ISIN] a new ISIN instance with calculated checksum
|
|
54
54
|
# @raise [InvalidFormatError] if the country code is not valid for SEDOL
|
|
55
55
|
def to_isin(country_code = 'GB')
|
|
56
56
|
unless ISIN_COUNTRY_CODES.include?(country_code)
|
|
@@ -60,9 +60,9 @@ module SecID
|
|
|
60
60
|
ISIN.new("#{country_code}00#{restore}").restore!
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
-
# @return [Integer] the calculated
|
|
63
|
+
# @return [Integer] the calculated checksum (0-9)
|
|
64
64
|
# @raise [InvalidFormatError] if the SEDOL format is invalid
|
|
65
|
-
def
|
|
65
|
+
def calculate_checksum
|
|
66
66
|
validate_format_for_calculation!
|
|
67
67
|
mod10(weighted_sum)
|
|
68
68
|
end
|
|
@@ -70,7 +70,7 @@ module SecID
|
|
|
70
70
|
# Generates a random SEDOL body: 6 characters excluding vowels.
|
|
71
71
|
#
|
|
72
72
|
# @param random [Random] source of randomness
|
|
73
|
-
# @return [String] a 6-character SEDOL body without
|
|
73
|
+
# @return [String] a 6-character SEDOL body without checksum
|
|
74
74
|
def self.generate_body(random)
|
|
75
75
|
random_string(GENERATE_CHARSET, 6, random: random)
|
|
76
76
|
end
|
|
@@ -79,7 +79,7 @@ module SecID
|
|
|
79
79
|
private
|
|
80
80
|
|
|
81
81
|
# @return [Hash]
|
|
82
|
-
def components = {
|
|
82
|
+
def components = { checksum: }
|
|
83
83
|
|
|
84
84
|
# NOTE: Not idiomatic Ruby, but optimized for performance.
|
|
85
85
|
#
|
data/lib/sec_id/upi.rb
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SecID
|
|
4
|
+
# Unique Product Identifier (UPI, ISO 4914) - a 12-character code that identifies
|
|
5
|
+
# OTC derivative products for regulatory reporting, issued by the ANNA Derivatives
|
|
6
|
+
# Service Bureau.
|
|
7
|
+
#
|
|
8
|
+
# Format: fixed 'QZ' prefix + 9-character body + 1 check character, drawn from a
|
|
9
|
+
# 30-symbol alphabet (digits 0-9 plus consonants; vowels and 'Y' never appear).
|
|
10
|
+
# Validated fully offline via ISO 7064 hybrid MOD 31,30 over the 11 preceding characters.
|
|
11
|
+
#
|
|
12
|
+
# @see https://www.anna-dsb.com
|
|
13
|
+
#
|
|
14
|
+
# @example Validate a UPI
|
|
15
|
+
# SecID::UPI.valid?('QZRBG6ZTKS42') #=> true
|
|
16
|
+
#
|
|
17
|
+
# @example Restore check character
|
|
18
|
+
# SecID::UPI.restore('QZRBG6ZTKS4') #=> 'QZRBG6ZTKS42'
|
|
19
|
+
class UPI < Base
|
|
20
|
+
include Checkable
|
|
21
|
+
|
|
22
|
+
# Human-readable name of the standard.
|
|
23
|
+
FULL_NAME = 'Unique Product Identifier'
|
|
24
|
+
# Valid length of a normalized identifier.
|
|
25
|
+
ID_LENGTH = 12
|
|
26
|
+
# A representative valid identifier.
|
|
27
|
+
EXAMPLE = 'QZRBG6ZTKS42'
|
|
28
|
+
# Pattern matching the identifier's permitted character set (digits + consonants, no vowels/Y).
|
|
29
|
+
VALID_CHARS_REGEX = /\A[0-9B-DF-HJ-NP-TV-XZ]+\z/
|
|
30
|
+
|
|
31
|
+
# Regular expression for parsing UPI components: fixed 'QZ' prefix, 9-character body,
|
|
32
|
+
# optional check character.
|
|
33
|
+
ID_REGEX = /\A
|
|
34
|
+
(?<identifier>QZ[0-9B-DF-HJ-NP-TV-XZ]{9})
|
|
35
|
+
(?<checksum>[0-9B-DF-HJ-NP-TV-XZ])?
|
|
36
|
+
\z/x
|
|
37
|
+
|
|
38
|
+
# The 30-symbol UPI alphabet, ordered by check-character value (0-29).
|
|
39
|
+
ALPHABET = '0123456789BCDFGHJKLMNPQRSTVWXZ'.chars.freeze
|
|
40
|
+
|
|
41
|
+
# Maps each alphabet character to its check-character value (0-29).
|
|
42
|
+
ALPHABET_VALUE = ALPHABET.each_with_index.to_h.freeze
|
|
43
|
+
|
|
44
|
+
# Characters valid in a UPI body (same alphabet as VALID_CHARS_REGEX).
|
|
45
|
+
GENERATE_CHARSET = ALPHANUMERIC.grep(VALID_CHARS_REGEX).freeze
|
|
46
|
+
|
|
47
|
+
# @param upi [String] the UPI string to parse
|
|
48
|
+
def initialize(upi)
|
|
49
|
+
upi_parts = parse upi
|
|
50
|
+
@identifier = upi_parts[:identifier]
|
|
51
|
+
@checksum = upi_parts[:checksum]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [String] the calculated check character
|
|
55
|
+
# @raise [InvalidFormatError] if the UPI format is invalid
|
|
56
|
+
def calculate_checksum
|
|
57
|
+
validate_format_for_calculation!
|
|
58
|
+
mod31_30_check_char(identifier, ALPHABET, ALPHABET_VALUE)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Generates a random UPI body: the fixed 'QZ' prefix plus 9 characters drawn
|
|
62
|
+
# from the UPI alphabet.
|
|
63
|
+
#
|
|
64
|
+
# @param random [Random] source of randomness
|
|
65
|
+
# @return [String] an 11-character UPI body without check character
|
|
66
|
+
def self.generate_body(random)
|
|
67
|
+
"QZ#{random_string(GENERATE_CHARSET, 9, random: random)}"
|
|
68
|
+
end
|
|
69
|
+
private_class_method :generate_body
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# @return [Hash]
|
|
74
|
+
def components = { checksum: }
|
|
75
|
+
end
|
|
76
|
+
end
|
data/lib/sec_id/valoren.rb
CHANGED
|
@@ -6,7 +6,7 @@ module SecID
|
|
|
6
6
|
#
|
|
7
7
|
# Format: 5-9 numeric digits
|
|
8
8
|
#
|
|
9
|
-
# @note Valoren identifiers have no
|
|
9
|
+
# @note Valoren identifiers have no checksum and validation is based solely on format.
|
|
10
10
|
#
|
|
11
11
|
# @see https://en.wikipedia.org/wiki/Valoren_number
|
|
12
12
|
#
|
|
@@ -52,7 +52,7 @@ module SecID
|
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
# @param country_code [String] the ISO 3166-1 alpha-2 country code (default: 'CH')
|
|
55
|
-
# @return [ISIN] a new ISIN instance with calculated
|
|
55
|
+
# @return [ISIN] a new ISIN instance with calculated checksum
|
|
56
56
|
# @raise [InvalidFormatError] if the country code is not CH or LI
|
|
57
57
|
def to_isin(country_code = 'CH')
|
|
58
58
|
unless ISIN_COUNTRY_CODES.include?(country_code)
|
data/lib/sec_id/version.rb
CHANGED
data/lib/sec_id/wkn.rb
CHANGED
|
@@ -38,7 +38,7 @@ module SecID
|
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
# @param country_code [String] the ISO 3166-1 alpha-2 country code (default: 'DE')
|
|
41
|
-
# @return [ISIN] a new ISIN instance with calculated
|
|
41
|
+
# @return [ISIN] a new ISIN instance with calculated checksum
|
|
42
42
|
# @raise [InvalidFormatError] if the country code is not DE
|
|
43
43
|
# @raise [InvalidFormatError] if the WKN format is invalid
|
|
44
44
|
def to_isin(country_code = 'DE')
|
data/lib/sec_id.rb
CHANGED
|
@@ -4,7 +4,7 @@ require 'sec_id/version'
|
|
|
4
4
|
|
|
5
5
|
# Toolkit for securities identifiers — validate, normalize, parse, detect, convert,
|
|
6
6
|
# generate, and classify ISIN, CUSIP, CEI, SEDOL, FIGI, LEI, IBAN, CIK, OCC, WKN,
|
|
7
|
-
# Valoren, CFI, FISN, BIC, and
|
|
7
|
+
# Valoren, CFI, FISN, BIC, DTI, and UPI.
|
|
8
8
|
#
|
|
9
9
|
# @see https://github.com/svyatov/sec_id
|
|
10
10
|
module SecID
|
|
@@ -14,8 +14,12 @@ module SecID
|
|
|
14
14
|
# Raised for invalid format, length, or characters.
|
|
15
15
|
class InvalidFormatError < Error; end
|
|
16
16
|
|
|
17
|
-
# Raised when the
|
|
18
|
-
class
|
|
17
|
+
# Raised when the checksum does not match the calculated value.
|
|
18
|
+
class InvalidChecksumError < Error; end
|
|
19
|
+
|
|
20
|
+
# @deprecated Use {InvalidChecksumError}. Kept as a v7 bridge (same class object,
|
|
21
|
+
# so `rescue` under either name keeps working); removed in v8.
|
|
22
|
+
InvalidCheckDigitError = InvalidChecksumError
|
|
19
23
|
|
|
20
24
|
# Raised for type-specific structural errors (invalid prefix, category, group, BBAN, or date).
|
|
21
25
|
class InvalidStructureError < Error; end
|
|
@@ -195,6 +199,7 @@ module SecID
|
|
|
195
199
|
end
|
|
196
200
|
end
|
|
197
201
|
|
|
202
|
+
require 'sec_id/deprecation'
|
|
198
203
|
require 'sec_id/deep_freeze'
|
|
199
204
|
require 'sec_id/errors'
|
|
200
205
|
require 'sec_id/concerns/normalizable'
|
|
@@ -219,6 +224,7 @@ require 'sec_id/cfi'
|
|
|
219
224
|
require 'sec_id/fisn'
|
|
220
225
|
require 'sec_id/bic'
|
|
221
226
|
require 'sec_id/dti'
|
|
227
|
+
require 'sec_id/upi'
|
|
222
228
|
|
|
223
229
|
# Auto-activate the ActiveModel validator inside Rails; a no-op everywhere else (keeps the
|
|
224
230
|
# default require zero-dependency). See SecID::Railtie and lib/sec_id/active_model.rb.
|
data/sec_id.gemspec
CHANGED
|
@@ -13,11 +13,11 @@ Gem::Specification.new do |spec|
|
|
|
13
13
|
spec.summary = 'A Ruby toolkit for securities identifiers — validate, parse, normalize, detect, convert, ' \
|
|
14
14
|
'generate, and classify.'
|
|
15
15
|
spec.description = 'Validate, normalize, parse, convert, generate, and classify securities identifiers. ' \
|
|
16
|
-
'Auto-detect identifier type from any string. Calculate and restore
|
|
16
|
+
'Auto-detect identifier type from any string. Calculate and restore checksums. Generate ' \
|
|
17
17
|
'format-valid identifiers as test fixtures. Decode CFI codes to full ISO 10962:2021 ' \
|
|
18
18
|
'classifications. Includes an opt-in ActiveModel/Rails validator that adds no runtime ' \
|
|
19
19
|
'dependency. Supports ISIN, CUSIP, CEI, SEDOL, FIGI, LEI, IBAN, CIK, OCC, WKN, Valoren, ' \
|
|
20
|
-
'CFI, FISN, BIC, and
|
|
20
|
+
'CFI, FISN, BIC, DTI, and UPI.'
|
|
21
21
|
spec.homepage = 'https://github.com/svyatov/sec_id'
|
|
22
22
|
spec.license = 'MIT'
|
|
23
23
|
|
data/sig/sec_id/base.rbs
CHANGED
|
@@ -42,7 +42,11 @@ module SecID
|
|
|
42
42
|
def self.length_values: () -> (::Array[Integer] | ::Range[Integer])
|
|
43
43
|
def self.length_specificity: () -> (Integer | Float | nil)
|
|
44
44
|
def self.example: () -> String
|
|
45
|
+
def self.has_checksum?: () -> bool
|
|
46
|
+
|
|
47
|
+
# @deprecated v7 bridge alias for `has_checksum?`; removed in v8.
|
|
45
48
|
def self.has_check_digit?: () -> bool
|
|
49
|
+
|
|
46
50
|
def self.inherited: (singleton(Base) subclass) -> void
|
|
47
51
|
|
|
48
52
|
def initialize: (SecID::input _sec_id_number) -> void
|
|
@@ -54,24 +58,25 @@ module SecID
|
|
|
54
58
|
def as_json: (*untyped) -> Hash[Symbol, untyped]
|
|
55
59
|
def deconstruct_keys: (::Array[Symbol]? _keys) -> Hash[Symbol, untyped]
|
|
56
60
|
|
|
57
|
-
#
|
|
61
|
+
# Checksum surface. Only the types that `include Checkable` implement these
|
|
58
62
|
# (they raise/NoMethodError otherwise); declared on Base so `Generatable#generate`
|
|
59
|
-
# and `Checkable::ClassMethods` type-check without narrowing on `
|
|
63
|
+
# and `Checkable::ClassMethods` type-check without narrowing on `has_checksum?`.
|
|
60
64
|
def restore: () -> String
|
|
61
65
|
def restore!: () -> self
|
|
62
|
-
def
|
|
66
|
+
def calculate_checksum: () -> (Integer | String)
|
|
63
67
|
|
|
64
68
|
def comparison_id: () -> String
|
|
65
69
|
|
|
66
70
|
private
|
|
67
71
|
|
|
72
|
+
def components_with_deprecation_bridge: () -> Hash[Symbol, untyped]
|
|
68
73
|
def components: () -> Hash[Symbol, untyped]
|
|
69
74
|
def parse: (SecID::input sec_id_number) -> (::MatchData | ::Hash[Symbol, untyped])
|
|
70
75
|
|
|
71
76
|
@full_id: String
|
|
72
77
|
@identifier: untyped
|
|
73
78
|
self.@short_name: String
|
|
74
|
-
self.@
|
|
79
|
+
self.@has_checksum: bool
|
|
75
80
|
self.@type_key: Symbol
|
|
76
81
|
self.@detection_priority: Array[Integer | Float | nil]
|
|
77
82
|
end
|
data/sig/sec_id/cei.rbs
CHANGED
|
@@ -15,7 +15,11 @@ module SecID
|
|
|
15
15
|
attr_reader entity_id: String?
|
|
16
16
|
|
|
17
17
|
def initialize: (SecID::input cei) -> void
|
|
18
|
+
def calculate_checksum: () -> Integer
|
|
19
|
+
def self.checksum: (SecID::input id) -> Integer
|
|
20
|
+
# @deprecated v7 bridge alias for `calculate_checksum`; removed in v8.
|
|
18
21
|
def calculate_check_digit: () -> Integer
|
|
22
|
+
# @deprecated v7 bridge alias for `checksum`; removed in v8.
|
|
19
23
|
def self.check_digit: (SecID::input id) -> Integer
|
|
20
24
|
private def self.generate_body: (Random random) -> String
|
|
21
25
|
|
|
@@ -1,26 +1,35 @@
|
|
|
1
1
|
module SecID
|
|
2
|
-
# Provides
|
|
2
|
+
# Provides checksum validation and calculation for identifiers with a checksum.
|
|
3
3
|
module Checkable : SecID::Base
|
|
4
4
|
CHAR_TO_DIGITS: Hash[String, Integer | Array[Integer]]
|
|
5
5
|
CHAR_TO_DIGIT: Hash[String, Integer]
|
|
6
6
|
|
|
7
7
|
def self.included: (Module base) -> void
|
|
8
8
|
|
|
9
|
-
attr_reader
|
|
9
|
+
attr_reader checksum: (Integer | String)?
|
|
10
10
|
|
|
11
11
|
# Class methods added when Checkable is included.
|
|
12
12
|
module ClassMethods : SecID::_IdentifierClass
|
|
13
|
-
def restore: (SecID::input
|
|
14
|
-
def restore!: (SecID::input
|
|
13
|
+
def restore: (SecID::input id_without_checksum) -> String
|
|
14
|
+
def restore!: (SecID::input id_without_checksum) -> Base
|
|
15
|
+
def checksum: (SecID::input id) -> (Integer | String)
|
|
16
|
+
|
|
17
|
+
# @deprecated v7 bridge alias for `checksum`; removed in v8.
|
|
15
18
|
def check_digit: (SecID::input id) -> (Integer | String)
|
|
16
19
|
end
|
|
17
20
|
|
|
18
21
|
def valid?: () -> bool
|
|
19
22
|
def restore: () -> String
|
|
20
23
|
def restore!: () -> self
|
|
21
|
-
def
|
|
24
|
+
def calculate_checksum: () -> (Integer | String)
|
|
22
25
|
def to_s: () -> String
|
|
23
26
|
|
|
27
|
+
# @deprecated v7 bridge alias for `checksum`; removed in v8.
|
|
28
|
+
def check_digit: () -> (Integer | String)?
|
|
29
|
+
|
|
30
|
+
# @deprecated v7 bridge alias for `calculate_checksum`; removed in v8.
|
|
31
|
+
def calculate_check_digit: () -> (Integer | String)
|
|
32
|
+
|
|
24
33
|
private
|
|
25
34
|
|
|
26
35
|
def luhn_sum_double_add_double: (Array[Integer] digits) -> Integer
|
|
@@ -29,13 +38,14 @@ module SecID
|
|
|
29
38
|
def reversed_digits_single: (untyped id) -> Array[Integer]
|
|
30
39
|
def reversed_digits_multi: (untyped id) -> Array[Integer]
|
|
31
40
|
def error_codes: () -> Array[Symbol]
|
|
32
|
-
def
|
|
41
|
+
def checksum_width: () -> Integer
|
|
33
42
|
def validation_message: (Symbol code) -> String?
|
|
34
43
|
def validate_format_for_calculation!: () -> void
|
|
35
44
|
def mod10: (Integer sum) -> Integer
|
|
36
45
|
def div10mod10: (Integer number) -> Integer
|
|
37
46
|
def mod97: (String numeric_string) -> Integer
|
|
47
|
+
def mod31_30_check_char: (untyped base, Array[String] alphabet, Hash[String, Integer] alphabet_value) -> String
|
|
38
48
|
|
|
39
|
-
@
|
|
49
|
+
@checksum: (Integer | String)?
|
|
40
50
|
end
|
|
41
51
|
end
|
|
@@ -10,7 +10,7 @@ module SecID
|
|
|
10
10
|
# The class-object surface Generatable's methods call once extended.
|
|
11
11
|
interface _GeneratableClass
|
|
12
12
|
def new: (SecID::input) -> Base
|
|
13
|
-
def
|
|
13
|
+
def has_checksum?: () -> bool
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
# Class methods added when Generatable is included.
|
|
@@ -33,7 +33,7 @@ module SecID
|
|
|
33
33
|
def detect_errors: () -> Array[Symbol]
|
|
34
34
|
def valid_length?: () -> bool
|
|
35
35
|
def valid_characters?: () -> bool
|
|
36
|
-
def
|
|
36
|
+
def checksum_width: () -> Integer
|
|
37
37
|
def validation_message: (Symbol code) -> String?
|
|
38
38
|
def build_error: (Symbol code, String? message) -> Errors::detail
|
|
39
39
|
|
data/sig/sec_id/cusip.rbs
CHANGED
|
@@ -16,7 +16,11 @@ module SecID
|
|
|
16
16
|
|
|
17
17
|
def initialize: (SecID::input cusip) -> void
|
|
18
18
|
def to_pretty_s: () -> String?
|
|
19
|
+
def calculate_checksum: () -> Integer
|
|
20
|
+
def self.checksum: (SecID::input id) -> Integer
|
|
21
|
+
# @deprecated v7 bridge alias for `calculate_checksum`; removed in v8.
|
|
19
22
|
def calculate_check_digit: () -> Integer
|
|
23
|
+
# @deprecated v7 bridge alias for `checksum`; removed in v8.
|
|
20
24
|
def self.check_digit: (SecID::input id) -> Integer
|
|
21
25
|
private def self.generate_body: (Random random) -> String
|
|
22
26
|
def to_isin: (String country_code) -> ISIN
|