sec_id 4.1.0 → 4.3.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 +47 -4
- data/README.md +157 -102
- data/lib/sec_id/base.rb +118 -6
- data/lib/sec_id/cik.rb +39 -11
- data/lib/sec_id/cusip.rb +30 -8
- data/lib/sec_id/figi.rb +30 -6
- data/lib/sec_id/iban/country_rules.rb +266 -0
- data/lib/sec_id/iban.rb +158 -0
- data/lib/sec_id/isin.rb +33 -12
- data/lib/sec_id/lei.rb +67 -0
- data/lib/sec_id/normalizable.rb +35 -0
- data/lib/sec_id/occ.rb +150 -0
- data/lib/sec_id/sedol.rb +24 -6
- data/lib/sec_id/version.rb +1 -1
- data/lib/sec_id.rb +4 -0
- data/sec_id.gemspec +3 -4
- metadata +10 -17
- data/.github/dependabot.yml +0 -11
- data/.github/workflows/main.yml +0 -39
- data/.gitignore +0 -12
- data/.rspec +0 -3
- data/.rubocop.yml +0 -39
- data/Gemfile +0 -20
- data/Rakefile +0 -13
- data/bin/console +0 -15
- data/bin/setup +0 -8
data/lib/sec_id/cik.rb
CHANGED
|
@@ -1,33 +1,61 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module SecId
|
|
4
|
-
#
|
|
4
|
+
# Central Index Key (CIK) - SEC identifier for entities filing with the SEC.
|
|
5
|
+
# A 1-10 digit number that uniquely identifies entities in SEC systems.
|
|
6
|
+
#
|
|
7
|
+
# @note CIK identifiers have no check digit. The {#has_check_digit?} method
|
|
8
|
+
# returns false and validation is based solely on format.
|
|
9
|
+
#
|
|
10
|
+
# @see https://en.wikipedia.org/wiki/Central_Index_Key
|
|
11
|
+
#
|
|
12
|
+
# @example Validate a CIK
|
|
13
|
+
# SecId::CIK.valid?('0001521365') #=> true
|
|
14
|
+
# SecId::CIK.valid?('1521365') #=> true
|
|
15
|
+
#
|
|
16
|
+
# @example Normalize a CIK to 10 digits
|
|
17
|
+
# SecId::CIK.normalize!('1521365') #=> '0001521365'
|
|
5
18
|
class CIK < Base
|
|
19
|
+
include Normalizable
|
|
20
|
+
|
|
21
|
+
# Regular expression for parsing CIK components.
|
|
6
22
|
ID_REGEX = /\A
|
|
7
23
|
(?=\d{1,10}\z)(?<padding>0*)(?<identifier>[1-9]\d{0,9})
|
|
8
24
|
\z/x
|
|
9
25
|
|
|
26
|
+
# @return [String, nil] the leading zeros in the CIK
|
|
10
27
|
attr_reader :padding
|
|
11
28
|
|
|
29
|
+
# @param cik [String, Integer] the CIK to parse
|
|
12
30
|
def initialize(cik)
|
|
13
|
-
cik_parts = parse
|
|
31
|
+
cik_parts = parse(cik)
|
|
14
32
|
@padding = cik_parts[:padding]
|
|
15
33
|
@identifier = cik_parts[:identifier]
|
|
34
|
+
@check_digit = nil
|
|
16
35
|
end
|
|
17
36
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def valid_format?
|
|
23
|
-
!identifier.nil?
|
|
37
|
+
# @return [Boolean] always false
|
|
38
|
+
def has_check_digit?
|
|
39
|
+
false
|
|
24
40
|
end
|
|
25
41
|
|
|
26
|
-
|
|
27
|
-
|
|
42
|
+
# Normalizes the CIK to a 10-digit zero-padded format.
|
|
43
|
+
# Updates both @full_number and @padding to reflect the normalized state.
|
|
44
|
+
#
|
|
45
|
+
# @return [String] the normalized 10-digit CIK
|
|
46
|
+
# @raise [InvalidFormatError] if the CIK format is invalid
|
|
47
|
+
def normalize!
|
|
48
|
+
raise InvalidFormatError, "CIK '#{full_number}' is invalid and cannot be normalized!" unless valid_format?
|
|
28
49
|
|
|
29
|
-
@padding = '0' * (10 - @identifier.length)
|
|
30
50
|
@full_number = @identifier.rjust(10, '0')
|
|
51
|
+
@padding = @full_number[0, 10 - @identifier.length]
|
|
52
|
+
@full_number
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [String]
|
|
56
|
+
def to_s
|
|
57
|
+
full_number
|
|
31
58
|
end
|
|
59
|
+
alias to_str to_s
|
|
32
60
|
end
|
|
33
61
|
end
|
data/lib/sec_id/cusip.rb
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module SecId
|
|
4
|
-
#
|
|
4
|
+
# Committee on Uniform Securities Identification Procedures (CUSIP) - a 9-character
|
|
5
|
+
# alphanumeric code that identifies North American securities.
|
|
6
|
+
#
|
|
7
|
+
# Format: 6-character issuer code (CUSIP-6) + 2-character issue number + 1-digit check digit
|
|
8
|
+
#
|
|
9
|
+
# @see https://en.wikipedia.org/wiki/CUSIP
|
|
10
|
+
#
|
|
11
|
+
# @example Validate a CUSIP
|
|
12
|
+
# SecId::CUSIP.valid?('037833100') #=> true
|
|
13
|
+
#
|
|
14
|
+
# @example Convert to ISIN
|
|
15
|
+
# cusip = SecId::CUSIP.new('037833100')
|
|
16
|
+
# cusip.to_isin('US') #=> #<SecId::ISIN>
|
|
5
17
|
class CUSIP < Base
|
|
18
|
+
# Regular expression for parsing CUSIP components.
|
|
6
19
|
ID_REGEX = /\A
|
|
7
20
|
(?<identifier>
|
|
8
21
|
(?<cusip6>[A-Z0-9]{5}[A-Z0-9*@#])
|
|
@@ -10,8 +23,13 @@ module SecId
|
|
|
10
23
|
(?<check_digit>\d)?
|
|
11
24
|
\z/x
|
|
12
25
|
|
|
13
|
-
|
|
26
|
+
# @return [String, nil] the 6-character issuer code
|
|
27
|
+
attr_reader :cusip6
|
|
14
28
|
|
|
29
|
+
# @return [String, nil] the 2-character issue number
|
|
30
|
+
attr_reader :issue
|
|
31
|
+
|
|
32
|
+
# @param cusip [String] the CUSIP string to parse
|
|
15
33
|
def initialize(cusip)
|
|
16
34
|
cusip_parts = parse cusip
|
|
17
35
|
@identifier = cusip_parts[:identifier]
|
|
@@ -20,14 +38,16 @@ module SecId
|
|
|
20
38
|
@check_digit = cusip_parts[:check_digit]&.to_i
|
|
21
39
|
end
|
|
22
40
|
|
|
41
|
+
# @return [Integer] the calculated check digit (0-9)
|
|
42
|
+
# @raise [InvalidFormatError] if the CUSIP format is invalid
|
|
23
43
|
def calculate_check_digit
|
|
24
|
-
|
|
25
|
-
raise InvalidFormatError, "CUSIP '#{full_number}' is invalid and check-digit cannot be calculated!"
|
|
26
|
-
end
|
|
27
|
-
|
|
44
|
+
validate_format_for_calculation!
|
|
28
45
|
mod10(modified_luhn_sum)
|
|
29
46
|
end
|
|
30
47
|
|
|
48
|
+
# @param country_code [String] the ISO 3166-1 alpha-2 country code (must be CGS country)
|
|
49
|
+
# @return [ISIN] a new ISIN instance
|
|
50
|
+
# @raise [InvalidFormatError] if the country code is not a CGS country
|
|
31
51
|
def to_isin(country_code)
|
|
32
52
|
unless ISIN::CGS_COUNTRY_CODES.include?(country_code)
|
|
33
53
|
raise(InvalidFormatError, "'#{country_code}' is not a CGS country code!")
|
|
@@ -39,14 +59,15 @@ module SecId
|
|
|
39
59
|
isin
|
|
40
60
|
end
|
|
41
61
|
|
|
42
|
-
#
|
|
62
|
+
# @return [Boolean] true if first character is a letter (CINS identifier)
|
|
43
63
|
def cins?
|
|
44
64
|
cusip6[0] < '0' || cusip6[0] > '9'
|
|
45
65
|
end
|
|
46
66
|
|
|
47
67
|
private
|
|
48
68
|
|
|
49
|
-
#
|
|
69
|
+
# @return [Integer] the modified Luhn sum
|
|
70
|
+
# @see https://en.wikipedia.org/wiki/Luhn_algorithm
|
|
50
71
|
def modified_luhn_sum
|
|
51
72
|
reversed_id_digits.each_slice(2).reduce(0) do |sum, (even, odd)|
|
|
52
73
|
double_even = (even || 0) * 2
|
|
@@ -54,6 +75,7 @@ module SecId
|
|
|
54
75
|
end
|
|
55
76
|
end
|
|
56
77
|
|
|
78
|
+
# @return [Array<Integer>] the reversed digit array
|
|
57
79
|
def reversed_id_digits
|
|
58
80
|
identifier.each_char.map(&method(:char_to_digit)).reverse!
|
|
59
81
|
end
|
data/lib/sec_id/figi.rb
CHANGED
|
@@ -3,7 +3,22 @@
|
|
|
3
3
|
require 'set'
|
|
4
4
|
|
|
5
5
|
module SecId
|
|
6
|
+
# Financial Instrument Global Identifier (FIGI) - a 12-character alphanumeric code
|
|
7
|
+
# that uniquely identifies financial instruments.
|
|
8
|
+
#
|
|
9
|
+
# Format: 2-character prefix + 'G' + 8-character random part + 1-digit check digit
|
|
10
|
+
# Note: FIGI excludes vowels (A, E, I, O, U) from valid characters.
|
|
11
|
+
#
|
|
12
|
+
# @see https://en.wikipedia.org/wiki/Financial_Instrument_Global_Identifier
|
|
13
|
+
#
|
|
14
|
+
# @example Validate a FIGI
|
|
15
|
+
# SecId::FIGI.valid?('BBG000BLNQ16') #=> true
|
|
16
|
+
#
|
|
17
|
+
# @example Restore check digit
|
|
18
|
+
# SecId::FIGI.restore!('BBG000BLNQ1') #=> 'BBG000BLNQ16'
|
|
6
19
|
class FIGI < Base
|
|
20
|
+
# Regular expression for parsing FIGI components.
|
|
21
|
+
# The third character must be 'G'. Excludes vowels from valid characters.
|
|
7
22
|
ID_REGEX = /\A
|
|
8
23
|
(?<identifier>
|
|
9
24
|
(?<prefix>[B-DF-HJ-NP-TV-Z0-9]{2})
|
|
@@ -12,10 +27,16 @@ module SecId
|
|
|
12
27
|
(?<check_digit>\d)?
|
|
13
28
|
\z/x
|
|
14
29
|
|
|
30
|
+
# Country-code prefixes that are restricted from use in FIGI.
|
|
15
31
|
RESTRICTED_PREFIXES = Set.new %w[BS BM GG GB GH KY VG]
|
|
16
32
|
|
|
17
|
-
|
|
33
|
+
# @return [String, nil] the 2-character prefix
|
|
34
|
+
attr_reader :prefix
|
|
18
35
|
|
|
36
|
+
# @return [String, nil] the 8-character random part
|
|
37
|
+
attr_reader :random_part
|
|
38
|
+
|
|
39
|
+
# @param figi [String] the FIGI string to parse
|
|
19
40
|
def initialize(figi)
|
|
20
41
|
figi_parts = parse figi
|
|
21
42
|
@identifier = figi_parts[:identifier]
|
|
@@ -24,28 +45,31 @@ module SecId
|
|
|
24
45
|
@check_digit = figi_parts[:check_digit]&.to_i
|
|
25
46
|
end
|
|
26
47
|
|
|
48
|
+
# @return [Boolean]
|
|
27
49
|
def valid_format?
|
|
28
50
|
!identifier.nil? && !RESTRICTED_PREFIXES.include?(prefix)
|
|
29
51
|
end
|
|
30
52
|
|
|
53
|
+
# @return [Integer] the calculated check digit (0-9)
|
|
54
|
+
# @raise [InvalidFormatError] if the FIGI format is invalid
|
|
31
55
|
def calculate_check_digit
|
|
32
|
-
|
|
33
|
-
raise InvalidFormatError, "FIGI '#{full_number}' is invalid and check-digit cannot be calculated!"
|
|
34
|
-
end
|
|
35
|
-
|
|
56
|
+
validate_format_for_calculation!
|
|
36
57
|
mod10(modified_luhn_sum)
|
|
37
58
|
end
|
|
38
59
|
|
|
39
60
|
private
|
|
40
61
|
|
|
41
62
|
# https://en.wikipedia.org/wiki/Luhn_algorithm
|
|
63
|
+
#
|
|
64
|
+
# @return [Integer] the modified Luhn sum
|
|
42
65
|
def modified_luhn_sum
|
|
43
66
|
reversed_id_digits.each_with_index.reduce(0) do |sum, (digit, index)|
|
|
44
67
|
digit *= 2 if index.odd?
|
|
45
|
-
sum + digit
|
|
68
|
+
sum + div10mod10(digit)
|
|
46
69
|
end
|
|
47
70
|
end
|
|
48
71
|
|
|
72
|
+
# @return [Array<Integer>] the identifier digits in reverse order
|
|
49
73
|
def reversed_id_digits
|
|
50
74
|
identifier.each_char.map(&method(:char_to_digit)).reverse!
|
|
51
75
|
end
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SecId
|
|
4
|
+
# Country-specific BBAN validation rules for IBAN
|
|
5
|
+
# rubocop:disable Metrics/ModuleLength
|
|
6
|
+
module IBANCountryRules
|
|
7
|
+
# Country-specific BBAN rules for EU/EEA countries
|
|
8
|
+
# Each entry defines:
|
|
9
|
+
# - :length => total BBAN length
|
|
10
|
+
# - :format => regex pattern for BBAN structure validation
|
|
11
|
+
# - :components => hash mapping component names to [start, length] positions
|
|
12
|
+
#
|
|
13
|
+
# Sources:
|
|
14
|
+
# - https://en.wikipedia.org/wiki/International_Bank_Account_Number
|
|
15
|
+
# - https://www.swift.com/standards/data-standards/iban-international-bank-account-number
|
|
16
|
+
COUNTRY_RULES = {
|
|
17
|
+
# Austria - 16 chars: 5-digit bank code + 11-digit account
|
|
18
|
+
'AT' => {
|
|
19
|
+
length: 16,
|
|
20
|
+
format: /\A\d{16}\z/,
|
|
21
|
+
components: { bank_code: [0, 5], account_number: [5, 11] }
|
|
22
|
+
},
|
|
23
|
+
# Belgium - 12 chars: 3-digit bank code + 7-digit account + 2-digit national check
|
|
24
|
+
'BE' => {
|
|
25
|
+
length: 12,
|
|
26
|
+
format: /\A\d{12}\z/,
|
|
27
|
+
components: { bank_code: [0, 3], account_number: [3, 7], national_check: [10, 2] }
|
|
28
|
+
},
|
|
29
|
+
# Bulgaria - 18 chars: 4-letter bank code + 4-digit branch + 2-digit account type + 8-digit account
|
|
30
|
+
'BG' => {
|
|
31
|
+
length: 18,
|
|
32
|
+
format: /\A[A-Z]{4}\d{14}\z/,
|
|
33
|
+
components: { bank_code: [0, 4], branch_code: [4, 4], account_number: [10, 8] }
|
|
34
|
+
},
|
|
35
|
+
# Croatia - 17 chars: 7-digit bank code + 10-digit account
|
|
36
|
+
'HR' => {
|
|
37
|
+
length: 17,
|
|
38
|
+
format: /\A\d{17}\z/,
|
|
39
|
+
components: { bank_code: [0, 7], account_number: [7, 10] }
|
|
40
|
+
},
|
|
41
|
+
# Cyprus - 24 chars: 3-digit bank code + 5-digit branch + 16-char account
|
|
42
|
+
'CY' => {
|
|
43
|
+
length: 24,
|
|
44
|
+
format: /\A\d{8}[A-Z0-9]{16}\z/,
|
|
45
|
+
components: { bank_code: [0, 3], branch_code: [3, 5], account_number: [8, 16] }
|
|
46
|
+
},
|
|
47
|
+
# Czech Republic - 20 chars: 4-digit bank code + 16-digit account
|
|
48
|
+
'CZ' => {
|
|
49
|
+
length: 20,
|
|
50
|
+
format: /\A\d{20}\z/,
|
|
51
|
+
components: { bank_code: [0, 4], account_number: [4, 16] }
|
|
52
|
+
},
|
|
53
|
+
# Denmark - 14 chars: 4-digit bank code + 10-digit account
|
|
54
|
+
'DK' => {
|
|
55
|
+
length: 14,
|
|
56
|
+
format: /\A\d{14}\z/,
|
|
57
|
+
components: { bank_code: [0, 4], account_number: [4, 10] }
|
|
58
|
+
},
|
|
59
|
+
# Estonia - 16 chars: 2-digit bank code + 14-digit account
|
|
60
|
+
'EE' => {
|
|
61
|
+
length: 16,
|
|
62
|
+
format: /\A\d{16}\z/,
|
|
63
|
+
components: { bank_code: [0, 2], account_number: [2, 14] }
|
|
64
|
+
},
|
|
65
|
+
# Finland - 14 chars: 3-digit bank code + 11-digit account
|
|
66
|
+
'FI' => {
|
|
67
|
+
length: 14,
|
|
68
|
+
format: /\A\d{14}\z/,
|
|
69
|
+
components: { bank_code: [0, 3], account_number: [3, 11] }
|
|
70
|
+
},
|
|
71
|
+
# France - 23 chars: 5-digit bank + 5-digit branch + 11-char account + 2-digit national check
|
|
72
|
+
'FR' => {
|
|
73
|
+
length: 23,
|
|
74
|
+
format: /\A\d{10}[A-Z0-9]{11}\d{2}\z/,
|
|
75
|
+
components: { bank_code: [0, 5], branch_code: [5, 5], account_number: [10, 11], national_check: [21, 2] }
|
|
76
|
+
},
|
|
77
|
+
# Germany - 18 chars: 8-digit bank code (Bankleitzahl) + 10-digit account
|
|
78
|
+
'DE' => {
|
|
79
|
+
length: 18,
|
|
80
|
+
format: /\A\d{18}\z/,
|
|
81
|
+
components: { bank_code: [0, 8], account_number: [8, 10] }
|
|
82
|
+
},
|
|
83
|
+
# Greece - 23 chars: 3-digit bank code + 4-digit branch + 16-digit account
|
|
84
|
+
'GR' => {
|
|
85
|
+
length: 23,
|
|
86
|
+
format: /\A\d{23}\z/,
|
|
87
|
+
components: { bank_code: [0, 3], branch_code: [3, 4], account_number: [7, 16] }
|
|
88
|
+
},
|
|
89
|
+
# Hungary - 24 chars: 3-digit bank code + 4-digit branch + 16-digit account + 1-digit national check
|
|
90
|
+
'HU' => {
|
|
91
|
+
length: 24,
|
|
92
|
+
format: /\A\d{24}\z/,
|
|
93
|
+
components: { bank_code: [0, 3], branch_code: [3, 4], account_number: [7, 16], national_check: [23, 1] }
|
|
94
|
+
},
|
|
95
|
+
# Iceland - 22 chars: 4-digit bank code + 2-digit branch + 6-digit account + 10-digit holder ID
|
|
96
|
+
'IS' => {
|
|
97
|
+
length: 22,
|
|
98
|
+
format: /\A\d{22}\z/,
|
|
99
|
+
components: { bank_code: [0, 4], branch_code: [4, 2], account_number: [6, 6] }
|
|
100
|
+
},
|
|
101
|
+
# Ireland - 18 chars: 4-letter bank code + 6-digit branch + 8-digit account
|
|
102
|
+
'IE' => {
|
|
103
|
+
length: 18,
|
|
104
|
+
format: /\A[A-Z]{4}\d{14}\z/,
|
|
105
|
+
components: { bank_code: [0, 4], branch_code: [4, 6], account_number: [10, 8] }
|
|
106
|
+
},
|
|
107
|
+
# Italy - 23 chars: 1-letter check + 5-digit bank code + 5-digit branch + 12-char account
|
|
108
|
+
'IT' => {
|
|
109
|
+
length: 23,
|
|
110
|
+
format: /\A[A-Z]\d{10}[A-Z0-9]{12}\z/,
|
|
111
|
+
components: { national_check: [0, 1], bank_code: [1, 5], branch_code: [6, 5], account_number: [11, 12] }
|
|
112
|
+
},
|
|
113
|
+
# Latvia - 17 chars: 4-letter bank code + 13-digit account
|
|
114
|
+
'LV' => {
|
|
115
|
+
length: 17,
|
|
116
|
+
format: /\A[A-Z]{4}[A-Z0-9]{13}\z/,
|
|
117
|
+
components: { bank_code: [0, 4], account_number: [4, 13] }
|
|
118
|
+
},
|
|
119
|
+
# Liechtenstein - 17 chars: 5-digit bank code + 12-char account
|
|
120
|
+
'LI' => {
|
|
121
|
+
length: 17,
|
|
122
|
+
format: /\A\d{5}[A-Z0-9]{12}\z/,
|
|
123
|
+
components: { bank_code: [0, 5], account_number: [5, 12] }
|
|
124
|
+
},
|
|
125
|
+
# Lithuania - 16 chars: 5-digit bank code + 11-digit account
|
|
126
|
+
'LT' => {
|
|
127
|
+
length: 16,
|
|
128
|
+
format: /\A\d{16}\z/,
|
|
129
|
+
components: { bank_code: [0, 5], account_number: [5, 11] }
|
|
130
|
+
},
|
|
131
|
+
# Luxembourg - 16 chars: 3-digit bank code + 13-char account
|
|
132
|
+
'LU' => {
|
|
133
|
+
length: 16,
|
|
134
|
+
format: /\A\d{3}[A-Z0-9]{13}\z/,
|
|
135
|
+
components: { bank_code: [0, 3], account_number: [3, 13] }
|
|
136
|
+
},
|
|
137
|
+
# Malta - 27 chars: 4-letter bank code + 5-digit branch + 18-char account
|
|
138
|
+
'MT' => {
|
|
139
|
+
length: 27,
|
|
140
|
+
format: /\A[A-Z]{4}\d{5}[A-Z0-9]{18}\z/,
|
|
141
|
+
components: { bank_code: [0, 4], branch_code: [4, 5], account_number: [9, 18] }
|
|
142
|
+
},
|
|
143
|
+
# Monaco - 23 chars: same format as France
|
|
144
|
+
'MC' => {
|
|
145
|
+
length: 23,
|
|
146
|
+
format: /\A\d{10}[A-Z0-9]{11}\d{2}\z/,
|
|
147
|
+
components: { bank_code: [0, 5], branch_code: [5, 5], account_number: [10, 11], national_check: [21, 2] }
|
|
148
|
+
},
|
|
149
|
+
# Netherlands - 14 chars: 4-letter bank code + 10-digit account
|
|
150
|
+
'NL' => {
|
|
151
|
+
length: 14,
|
|
152
|
+
format: /\A[A-Z]{4}\d{10}\z/,
|
|
153
|
+
components: { bank_code: [0, 4], account_number: [4, 10] }
|
|
154
|
+
},
|
|
155
|
+
# Norway - 11 chars: 4-digit bank code + 6-digit account + 1-digit national check
|
|
156
|
+
'NO' => {
|
|
157
|
+
length: 11,
|
|
158
|
+
format: /\A\d{11}\z/,
|
|
159
|
+
components: { bank_code: [0, 4], account_number: [4, 6], national_check: [10, 1] }
|
|
160
|
+
},
|
|
161
|
+
# Poland - 24 chars: 3-digit bank code + 4-digit branch + 1-digit check + 16-digit account
|
|
162
|
+
'PL' => {
|
|
163
|
+
length: 24,
|
|
164
|
+
format: /\A\d{24}\z/,
|
|
165
|
+
components: { bank_code: [0, 3], branch_code: [3, 4], national_check: [7, 1], account_number: [8, 16] }
|
|
166
|
+
},
|
|
167
|
+
# Portugal - 21 chars: 4-digit bank code + 4-digit branch + 11-digit account + 2-digit national check
|
|
168
|
+
'PT' => {
|
|
169
|
+
length: 21,
|
|
170
|
+
format: /\A\d{21}\z/,
|
|
171
|
+
components: { bank_code: [0, 4], branch_code: [4, 4], account_number: [8, 11], national_check: [19, 2] }
|
|
172
|
+
},
|
|
173
|
+
# Romania - 20 chars: 4-letter bank code + 16-char account
|
|
174
|
+
'RO' => {
|
|
175
|
+
length: 20,
|
|
176
|
+
format: /\A[A-Z]{4}[A-Z0-9]{16}\z/,
|
|
177
|
+
components: { bank_code: [0, 4], account_number: [4, 16] }
|
|
178
|
+
},
|
|
179
|
+
# San Marino - 23 chars: same format as Italy
|
|
180
|
+
'SM' => {
|
|
181
|
+
length: 23,
|
|
182
|
+
format: /\A[A-Z]\d{10}[A-Z0-9]{12}\z/,
|
|
183
|
+
components: { national_check: [0, 1], bank_code: [1, 5], branch_code: [6, 5], account_number: [11, 12] }
|
|
184
|
+
},
|
|
185
|
+
# Slovakia - 20 chars: 4-digit bank code + 16-digit account
|
|
186
|
+
'SK' => {
|
|
187
|
+
length: 20,
|
|
188
|
+
format: /\A\d{20}\z/,
|
|
189
|
+
components: { bank_code: [0, 4], account_number: [4, 16] }
|
|
190
|
+
},
|
|
191
|
+
# Slovenia - 15 chars: 5-digit bank code + 8-digit account + 2-digit national check
|
|
192
|
+
'SI' => {
|
|
193
|
+
length: 15,
|
|
194
|
+
format: /\A\d{15}\z/,
|
|
195
|
+
components: { bank_code: [0, 5], account_number: [5, 8], national_check: [13, 2] }
|
|
196
|
+
},
|
|
197
|
+
# Spain - 20 chars: 4-digit bank code + 4-digit branch + 2-digit national check + 10-digit account
|
|
198
|
+
'ES' => {
|
|
199
|
+
length: 20,
|
|
200
|
+
format: /\A\d{20}\z/,
|
|
201
|
+
components: { bank_code: [0, 4], branch_code: [4, 4], national_check: [8, 2], account_number: [10, 10] }
|
|
202
|
+
},
|
|
203
|
+
# Sweden - 20 chars: 3-digit bank code + 17-digit account
|
|
204
|
+
'SE' => {
|
|
205
|
+
length: 20,
|
|
206
|
+
format: /\A\d{20}\z/,
|
|
207
|
+
components: { bank_code: [0, 3], account_number: [3, 17] }
|
|
208
|
+
},
|
|
209
|
+
# Switzerland - 17 chars: 5-digit bank code + 12-char account
|
|
210
|
+
'CH' => {
|
|
211
|
+
length: 17,
|
|
212
|
+
format: /\A\d{5}[A-Z0-9]{12}\z/,
|
|
213
|
+
components: { bank_code: [0, 5], account_number: [5, 12] }
|
|
214
|
+
},
|
|
215
|
+
# United Kingdom - 18 chars: 4-letter bank code + 6-digit branch (sort code) + 8-digit account
|
|
216
|
+
'GB' => {
|
|
217
|
+
length: 18,
|
|
218
|
+
format: /\A[A-Z]{4}\d{14}\z/,
|
|
219
|
+
components: { bank_code: [0, 4], branch_code: [4, 6], account_number: [10, 8] }
|
|
220
|
+
}
|
|
221
|
+
}.freeze
|
|
222
|
+
|
|
223
|
+
# Countries where only length validation is performed (non-EU/EEA countries)
|
|
224
|
+
# Format: country_code => expected BBAN length
|
|
225
|
+
LENGTH_ONLY_COUNTRIES = {
|
|
226
|
+
'AD' => 20, # Andorra
|
|
227
|
+
'AE' => 19, # UAE
|
|
228
|
+
'AL' => 24, # Albania
|
|
229
|
+
'AZ' => 24, # Azerbaijan
|
|
230
|
+
'BA' => 16, # Bosnia and Herzegovina
|
|
231
|
+
'BY' => 24, # Belarus
|
|
232
|
+
'DO' => 24, # Dominican Republic
|
|
233
|
+
'EG' => 25, # Egypt
|
|
234
|
+
'GE' => 18, # Georgia
|
|
235
|
+
'GI' => 19, # Gibraltar
|
|
236
|
+
'GT' => 24, # Guatemala
|
|
237
|
+
'IL' => 19, # Israel
|
|
238
|
+
'IQ' => 19, # Iraq
|
|
239
|
+
'JO' => 26, # Jordan
|
|
240
|
+
'KW' => 26, # Kuwait
|
|
241
|
+
'KZ' => 16, # Kazakhstan
|
|
242
|
+
'LB' => 24, # Lebanon
|
|
243
|
+
'LC' => 28, # Saint Lucia
|
|
244
|
+
'MD' => 20, # Moldova
|
|
245
|
+
'ME' => 18, # Montenegro
|
|
246
|
+
'MK' => 15, # North Macedonia
|
|
247
|
+
'MR' => 23, # Mauritania
|
|
248
|
+
'MU' => 26, # Mauritius
|
|
249
|
+
'PS' => 25, # Palestine
|
|
250
|
+
'QA' => 25, # Qatar
|
|
251
|
+
'RS' => 18, # Serbia
|
|
252
|
+
'SA' => 20, # Saudi Arabia
|
|
253
|
+
'SC' => 27, # Seychelles
|
|
254
|
+
'ST' => 21, # Sao Tome and Principe
|
|
255
|
+
'SV' => 24, # El Salvador
|
|
256
|
+
'TL' => 19, # Timor-Leste
|
|
257
|
+
'TN' => 20, # Tunisia
|
|
258
|
+
'TR' => 22, # Turkey
|
|
259
|
+
'UA' => 25, # Ukraine
|
|
260
|
+
'VA' => 18, # Vatican City
|
|
261
|
+
'VG' => 20, # British Virgin Islands
|
|
262
|
+
'XK' => 16 # Kosovo
|
|
263
|
+
}.freeze
|
|
264
|
+
end
|
|
265
|
+
# rubocop:enable Metrics/ModuleLength
|
|
266
|
+
end
|
data/lib/sec_id/iban.rb
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'iban/country_rules'
|
|
4
|
+
|
|
5
|
+
module SecId
|
|
6
|
+
# International Bank Account Number (IBAN) - an international standard for identifying
|
|
7
|
+
# bank accounts across national borders (ISO 13616).
|
|
8
|
+
#
|
|
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.
|
|
11
|
+
#
|
|
12
|
+
# @see https://en.wikipedia.org/wiki/International_Bank_Account_Number
|
|
13
|
+
# @see https://www.iban.com/structure
|
|
14
|
+
#
|
|
15
|
+
# @example Validate an IBAN
|
|
16
|
+
# SecId::IBAN.valid?('DE89370400440532013000') #=> true
|
|
17
|
+
#
|
|
18
|
+
# @example Restore check digits
|
|
19
|
+
# SecId::IBAN.restore!('DE00370400440532013000') #=> 'DE89370400440532013000'
|
|
20
|
+
class IBAN < Base
|
|
21
|
+
include IBANCountryRules
|
|
22
|
+
|
|
23
|
+
# Regular expression for parsing IBAN components.
|
|
24
|
+
# Note: Check digit positioning is handled in initialize, not in the regex.
|
|
25
|
+
ID_REGEX = /\A
|
|
26
|
+
(?<country_code>[A-Z]{2})
|
|
27
|
+
(?<rest>[A-Z0-9]{13,32})
|
|
28
|
+
\z/x
|
|
29
|
+
|
|
30
|
+
# @return [String, nil] the ISO 3166-1 alpha-2 country code
|
|
31
|
+
attr_reader :country_code
|
|
32
|
+
|
|
33
|
+
# @return [String, nil] the Basic Bank Account Number (country-specific format)
|
|
34
|
+
attr_reader :bban
|
|
35
|
+
|
|
36
|
+
# @return [String, nil] the bank code (extracted from BBAN if country rules define it)
|
|
37
|
+
attr_reader :bank_code
|
|
38
|
+
|
|
39
|
+
# @return [String, nil] the branch code (extracted from BBAN if country rules define it)
|
|
40
|
+
attr_reader :branch_code
|
|
41
|
+
|
|
42
|
+
# @return [String, nil] the account number (extracted from BBAN if country rules define it)
|
|
43
|
+
attr_reader :account_number
|
|
44
|
+
|
|
45
|
+
# @return [String, nil] the national check digit (extracted from BBAN if country rules define it)
|
|
46
|
+
attr_reader :national_check
|
|
47
|
+
|
|
48
|
+
# @param iban [String] the IBAN string to parse
|
|
49
|
+
def initialize(iban)
|
|
50
|
+
iban_parts = parse(iban)
|
|
51
|
+
@country_code = iban_parts[:country_code]
|
|
52
|
+
rest = iban_parts[:rest]
|
|
53
|
+
|
|
54
|
+
if @country_code && rest
|
|
55
|
+
extract_check_digit_and_bban(rest)
|
|
56
|
+
@identifier = "#{@country_code}#{@bban}" if @bban
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
extract_bban_components if valid_format?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @return [Integer] the calculated 2-digit check value (1-98)
|
|
63
|
+
# @raise [InvalidFormatError] if the IBAN format is invalid
|
|
64
|
+
def calculate_check_digit
|
|
65
|
+
validate_format_for_calculation!
|
|
66
|
+
mod97(numeric_string_for_check)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @return [Boolean]
|
|
70
|
+
def valid_format?
|
|
71
|
+
return false unless identifier
|
|
72
|
+
|
|
73
|
+
valid_bban_format?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @return [Boolean]
|
|
77
|
+
def valid_bban_format?
|
|
78
|
+
return false unless bban
|
|
79
|
+
|
|
80
|
+
rule = country_rule
|
|
81
|
+
return valid_bban_length_only? unless rule
|
|
82
|
+
|
|
83
|
+
bban.length == rule[:length] && bban.match?(rule[:format])
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @return [Hash, nil] the validation rule or nil if country is unknown
|
|
87
|
+
def country_rule
|
|
88
|
+
COUNTRY_RULES[country_code]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @return [Boolean]
|
|
92
|
+
def known_country?
|
|
93
|
+
COUNTRY_RULES.key?(country_code) || LENGTH_ONLY_COUNTRIES.key?(country_code)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @return [String]
|
|
97
|
+
def to_s
|
|
98
|
+
return full_number unless check_digit
|
|
99
|
+
|
|
100
|
+
"#{country_code}#{check_digit.to_s.rjust(2, '0')}#{bban}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
# @param rest [String] the IBAN string after country code
|
|
106
|
+
# @return [void]
|
|
107
|
+
def extract_check_digit_and_bban(rest)
|
|
108
|
+
expected = expected_bban_length_for_country
|
|
109
|
+
|
|
110
|
+
if check_digits?(rest, expected)
|
|
111
|
+
@check_digit = rest[0, 2].to_i
|
|
112
|
+
@bban = rest[2..]
|
|
113
|
+
else
|
|
114
|
+
@check_digit = nil
|
|
115
|
+
@bban = rest
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @return [Integer, nil] the expected BBAN length or nil if unknown
|
|
120
|
+
def expected_bban_length_for_country
|
|
121
|
+
COUNTRY_RULES.dig(country_code, :length) || LENGTH_ONLY_COUNTRIES[country_code]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# @param rest [String] the IBAN string after country code
|
|
125
|
+
# @param expected_bban_length [Integer, nil] the expected BBAN length for the country
|
|
126
|
+
# @return [Boolean]
|
|
127
|
+
def check_digits?(rest, expected_bban_length)
|
|
128
|
+
return false unless rest[0, 2].match?(/\A\d{2}\z/)
|
|
129
|
+
return true unless expected_bban_length
|
|
130
|
+
|
|
131
|
+
# If we know expected BBAN length, check if rest matches with or without check digits
|
|
132
|
+
rest.length == expected_bban_length + 2 || rest.length != expected_bban_length
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @return [void]
|
|
136
|
+
def extract_bban_components
|
|
137
|
+
rule = country_rule
|
|
138
|
+
return unless rule&.key?(:components)
|
|
139
|
+
|
|
140
|
+
rule[:components].each do |name, (start, length)|
|
|
141
|
+
instance_variable_set(:"@#{name}", bban[start, length])
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @return [Boolean]
|
|
146
|
+
def valid_bban_length_only?
|
|
147
|
+
expected_length = LENGTH_ONLY_COUNTRIES[country_code]
|
|
148
|
+
return true unless expected_length
|
|
149
|
+
|
|
150
|
+
bban.length == expected_length
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# @return [String] the numeric string representation
|
|
154
|
+
def numeric_string_for_check
|
|
155
|
+
"#{bban}#{country_code}00".each_char.map { |char| char_to_digit(char) }.join
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|