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.
@@ -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 SecId
3
+ module SecID
4
4
  # Committee on Uniform Securities Identification Procedures (CUSIP) - a 9-character
5
5
  # alphanumeric code that identifies North American securities.
6
6
  #
@@ -9,14 +9,19 @@ module SecId
9
9
  # @see https://en.wikipedia.org/wiki/CUSIP
10
10
  #
11
11
  # @example Validate a CUSIP
12
- # SecId::CUSIP.valid?('037833100') #=> true
12
+ # SecID::CUSIP.valid?('037833100') #=> true
13
13
  #
14
14
  # @example Convert to ISIN
15
- # cusip = SecId::CUSIP.new('037833100')
16
- # cusip.to_isin('US') #=> #<SecId::ISIN>
15
+ # cusip = SecID::CUSIP.new('037833100')
16
+ # cusip.to_isin('US') #=> #<SecID::ISIN>
17
17
  class CUSIP < Base
18
18
  include Checkable
19
19
 
20
+ FULL_NAME = 'Committee on Uniform Securities Identification Procedures'
21
+ ID_LENGTH = 9
22
+ EXAMPLE = '037833100'
23
+ VALID_CHARS_REGEX = /\A[A-Z0-9*@#]+\z/
24
+
20
25
  # Regular expression for parsing CUSIP components.
21
26
  ID_REGEX = /\A
22
27
  (?<identifier>
@@ -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
- cusip_with_check_digit = "#{identifier}#{check_digit || calculate_check_digit}"
59
- isin = ISIN.new(country_code + cusip_with_check_digit)
60
- isin.restore!
61
- isin
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
- require 'set'
4
-
5
- module SecId
3
+ module SecID
6
4
  # Financial Instrument Global Identifier (FIGI) - a 12-character alphanumeric code
7
5
  # that uniquely identifies financial instruments.
8
6
  #
@@ -12,13 +10,18 @@ module SecId
12
10
  # @see https://en.wikipedia.org/wiki/Financial_Instrument_Global_Identifier
13
11
  #
14
12
  # @example Validate a FIGI
15
- # SecId::FIGI.valid?('BBG000BLNQ16') #=> true
13
+ # SecID::FIGI.valid?('BBG000BLNQ16') #=> true
16
14
  #
17
15
  # @example Restore check digit
18
- # SecId::FIGI.restore!('BBG000BLNQ1') #=> 'BBG000BLNQ16'
16
+ # SecID::FIGI.restore!('BBG000BLNQ1') #=> #<SecID::FIGI>
19
17
  class FIGI < Base
20
18
  include Checkable
21
19
 
20
+ FULL_NAME = 'Financial Instrument Global Identifier'
21
+ ID_LENGTH = 12
22
+ EXAMPLE = 'BBG000BLNNH6'
23
+ VALID_CHARS_REGEX = /\A[B-DF-HJ-NP-TV-Z0-9]+\z/
24
+
22
25
  # Regular expression for parsing FIGI components.
23
26
  # The third character must be 'G'. Excludes vowels from valid characters.
24
27
  ID_REGEX = /\A
@@ -47,9 +50,11 @@ module SecId
47
50
  @check_digit = figi_parts[:check_digit]&.to_i
48
51
  end
49
52
 
50
- # @return [Boolean]
51
- def valid_format?
52
- !identifier.nil? && !RESTRICTED_PREFIXES.include?(prefix)
53
+ # @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 SecId
3
+ module SecID
4
4
  # Financial Instrument Short Name (FISN) - a human-readable short name for financial
5
5
  # instruments per ISO 18774.
6
6
  #
@@ -13,14 +13,20 @@ module SecId
13
13
  # @see https://en.wikipedia.org/wiki/ISO_18774
14
14
  #
15
15
  # @example Validate a FISN
16
- # SecId::FISN.valid?('APPLE INC/SH') #=> true
17
- # SecId::FISN.valid?('apple inc/sh') #=> true (normalized to uppercase)
16
+ # SecID::FISN.valid?('APPLE INC/SH') #=> true
17
+ # SecID::FISN.valid?('apple inc/sh') #=> true (normalized to uppercase)
18
18
  #
19
19
  # @example Access FISN components
20
- # fisn = SecId::FISN.new('APPLE INC/SH')
20
+ # fisn = SecID::FISN.new('APPLE INC/SH')
21
21
  # fisn.issuer #=> 'APPLE INC'
22
22
  # fisn.description #=> 'SH'
23
23
  class FISN < Base
24
+ FULL_NAME = 'Financial Instrument Short Name'
25
+ ID_LENGTH = (3..35)
26
+ EXAMPLE = 'APPLE INC/SH'
27
+ VALID_CHARS_REGEX = %r{\A[A-Z0-9 /]+\z}
28
+ SEPARATORS = /-/
29
+
24
30
  # Regular expression for parsing FISN components.
25
31
  # Issuer: 1-15 chars, Description: 1-19 chars, Total: max 35 chars
26
32
  ID_REGEX = %r{\A
@@ -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 SecId
4
- # Country-specific BBAN validation rules for IBAN
3
+ module SecID
4
+ # Country-specific BBAN validation rules for IBAN.
5
+ #
6
+ # @api private
5
7
  # rubocop:disable Metrics/ModuleLength
6
8
  module IBANCountryRules
7
9
  # Country-specific BBAN rules for EU/EEA countries
data/lib/sec_id/iban.rb CHANGED
@@ -2,25 +2,30 @@
2
2
 
3
3
  require_relative 'iban/country_rules'
4
4
 
5
- module SecId
5
+ module SecID
6
6
  # International Bank Account Number (IBAN) - an international standard for identifying
7
7
  # bank accounts across national borders (ISO 13616).
8
8
  #
9
9
  # Format: 2-letter country code + 2-digit check digits + BBAN (Basic Bank Account Number, 11-30 chars)
10
- # Note: Unlike other SecId identifiers, the check digits are in positions 3-4, not at the end.
10
+ # Note: Unlike other SecID identifiers, the check digits are in positions 3-4, not at the end.
11
11
  #
12
12
  # @see https://en.wikipedia.org/wiki/International_Bank_Account_Number
13
13
  # @see https://www.iban.com/structure
14
14
  #
15
15
  # @example Validate an IBAN
16
- # SecId::IBAN.valid?('DE89370400440532013000') #=> true
16
+ # SecID::IBAN.valid?('DE89370400440532013000') #=> true
17
17
  #
18
18
  # @example Restore check digits
19
- # SecId::IBAN.restore!('DE00370400440532013000') #=> 'DE89370400440532013000'
19
+ # SecID::IBAN.restore!('DE00370400440532013000') #=> #<SecID::IBAN>
20
20
  class IBAN < Base
21
21
  include Checkable
22
22
  include IBANCountryRules
23
23
 
24
+ FULL_NAME = 'International Bank Account Number'
25
+ ID_LENGTH = (15..34)
26
+ EXAMPLE = 'GB29NWBK60161331926819'
27
+ VALID_CHARS_REGEX = /\A[A-Z0-9]+\z/
28
+
24
29
  # Regular expression for parsing IBAN components.
25
30
  # Note: Check digit positioning is handled in initialize, not in the regex.
26
31
  ID_REGEX = /\A
@@ -60,6 +65,13 @@ module SecId
60
65
  extract_bban_components if valid_format?
61
66
  end
62
67
 
68
+ # @return [String]
69
+ # @raise [InvalidFormatError] if the IBAN format is invalid
70
+ def restore
71
+ cd = calculate_check_digit
72
+ "#{country_code}#{cd.to_s.rjust(2, '0')}#{bban}"
73
+ end
74
+
63
75
  # @return [Integer] the calculated 2-digit check value (1-98)
64
76
  # @raise [InvalidFormatError] if the IBAN format is invalid
65
77
  def calculate_check_digit
@@ -67,13 +79,6 @@ module SecId
67
79
  mod97(numeric_string_for_check)
68
80
  end
69
81
 
70
- # @return [Boolean]
71
- def valid_format?
72
- return false unless identifier
73
-
74
- valid_bban_format?
75
- end
76
-
77
82
  # @return [Boolean]
78
83
  def valid_bban_format?
79
84
  return false unless bban
@@ -96,13 +101,55 @@ module SecId
96
101
 
97
102
  # @return [String]
98
103
  def to_s
99
- return full_number unless check_digit
104
+ return full_id unless check_digit
100
105
 
101
106
  "#{country_code}#{check_digit.to_s.rjust(2, '0')}#{bban}"
102
107
  end
103
108
 
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)