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.
data/lib/sec_id/cik.rb CHANGED
@@ -1,33 +1,61 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SecId
4
- # https://en.wikipedia.org/wiki/Central_Index_Key
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 cik
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
- def valid?
19
- valid_format?
20
- end
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
- def restore!
27
- raise InvalidFormatError, "CIK '#{full_number}' is invalid and cannot be restored!" unless valid_format?
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
- # https://en.wikipedia.org/wiki/CUSIP
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
- attr_reader :cusip6, :issue
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
- unless valid_format?
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
- # CUSIP International Numbering System
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
- # https://en.wikipedia.org/wiki/Luhn_algorithm
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
- attr_reader :prefix, :random_part
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
- unless valid_format?
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.divmod(10).sum
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
@@ -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