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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a3391006ed4c0e135a0e94ef0d43da6aae3146a178f13c6237aec8d940931c52
4
- data.tar.gz: a572d9b26de1c8c97476cd954caeefacc9fe44ac8ffff7f25d9b4a72ca058e54
3
+ metadata.gz: 227ed0f22f9d18523b28c4553fd0ce5765a3367eae8e39017a8ce006d9f161fa
4
+ data.tar.gz: e46b18732147a27e2cdd0d74728619c4ba10bfd8210982a45d40358ee3f7c06a
5
5
  SHA512:
6
- metadata.gz: 1d380490f4a25d1044734b7724bf7e752e7dbb43097a468d3210e8fd9f4efd185beac207d9b5adfc5cce0cedfd5ea944be269785f444fd7a4bbb449b63ea9962
7
- data.tar.gz: 30434ebe94c7f2c7f3a311ab2a3857af1ae0b66b5993c3b659fd9f6852633171cd7e4ce3c73f7d1e554211cbb53259e1e7679236df243f8dfaf311a0a61cebee
6
+ metadata.gz: ddca4d991fcacd93520d36b87697a39d8121cbad2c8d70002c4dcc56334a6d8f3d6f58138b34d1ef2eb3f3b2e4eae284e10e1a8a2adb5d10d858aa956d809b94
7
+ data.tar.gz: 766cc6f3bacd4210c52364e2c5972b0311a2a0d3f9c20c6cac02735a7eb36b9b9a601bd21a01a1e69b0a7346c44079e59bd6ac0ecf377be4e880bee80731e27d
data/CHANGELOG.md CHANGED
@@ -1,13 +1,56 @@
1
1
  # Changelog
2
2
 
3
+ ## [Unreleased]
4
+
5
+ ## [4.3.0] - 2025-01-13
6
+
7
+ ### Added
8
+
9
+ - LEI support (Legal Entity Identifier, ISO 17442)
10
+ - IBAN support (International Bank Account Number, ISO 13616) with EU/EEA country validation
11
+
12
+ ### Updated
13
+
14
+ - Improved README: better formatting, navigation, and clear API distinction between check-digit and normalization identifiers
15
+
16
+ ### Internal
17
+
18
+ - Refactored CIK and OCC to inherit from Base class with `has_check_digit?` hook for cleaner architecture
19
+ - Added `Normalizable` module for consistent `normalize!` class method across identifiers
20
+ - Added `validate_format_for_calculation!` helper method to Base class to reduce code duplication
21
+ - Added comprehensive YARD documentation to all classes (public and private methods)
22
+ - Applied Stepdown Rule to method ordering throughout codebase
23
+ - Created shared RSpec examples for edge cases (nil, empty, whitespace inputs)
24
+ - Created shared RSpec examples for check-digit and normalization identifiers
25
+ - Applied shared examples to all identifier specs, removing ~350 lines of duplicate test code
26
+ - Improved test maintainability with 582 tests covering all identifier types
27
+
28
+ ## [4.2.0] - 2025-01-12
29
+
30
+ ### Added
31
+
32
+ - OCC support ([@wtn](https://github.com/wtn), [#93](https://github.com/svyatov/sec_id/pull/93))
33
+
34
+ ### Fixed
35
+
36
+ - CUSIP#cins? usage example in README ([@wtn](https://github.com/wtn), [#91](https://github.com/svyatov/sec_id/pull/91))
37
+
38
+ ### Updated
39
+
40
+ - Separate CIK from Base for cleaner architecture ([@wtn](https://github.com/wtn), [#92](https://github.com/svyatov/sec_id/pull/92))
41
+ - Use rubocop-rspec plugin ([@wtn](https://github.com/wtn), [#90](https://github.com/svyatov/sec_id/pull/90))
42
+ - Replace CodeClimate with Codecov
43
+ - Add permissions to CI workflow
44
+ - Clean up gemspec: update description and simplify files list
45
+
3
46
  ## [4.1.0] - 2024-09-23
4
47
 
5
48
  ### Added
6
49
 
7
- - FIGI support ([@wtn][], #84)
8
- - CIK support ([@wtn][], #85)
9
- - Convert between CUSIPs and ISINs ([@wtn][], #86, #88)
10
- - CINS check method for CUSIPs ([@wtn][], #87)
50
+ - FIGI support ([@wtn](https://github.com/wtn), [#84](https://github.com/svyatov/sec_id/pull/84))
51
+ - CIK support ([@wtn](https://github.com/wtn), [#85](https://github.com/svyatov/sec_id/pull/85))
52
+ - Convert between CUSIPs and ISINs ([@wtn](https://github.com/wtn), [#86](https://github.com/svyatov/sec_id/pull/86), [#88](https://github.com/svyatov/sec_id/pull/88))
53
+ - CINS check method for CUSIPs ([@wtn](https://github.com/wtn), [#87](https://github.com/svyatov/sec_id/pull/87))
11
54
 
12
55
  ### Updated
13
56
 
data/README.md CHANGED
@@ -1,104 +1,62 @@
1
- # SecId
2
- [![Gem Version](https://badge.fury.io/rb/sec_id.svg)](https://badge.fury.io/rb/sec_id)
3
- ![Build Status](https://github.com/svyatov/sec_id/actions/workflows/main.yml/badge.svg?branch=main)
4
- [![Maintainability](https://api.codeclimate.com/v1/badges/a4759963a5ddc4d55b24/maintainability)](https://codeclimate.com/github/svyatov/sec_id/maintainability)
5
- [![Test Coverage](https://api.codeclimate.com/v1/badges/a4759963a5ddc4d55b24/test_coverage)](https://codeclimate.com/github/svyatov/sec_id/test_coverage)
1
+ # SecId [![Gem Version](https://img.shields.io/gem/v/sec_id)](https://rubygems.org/gems/sec_id) [![Codecov](https://img.shields.io/codecov/c/github/svyatov/sec_id)](https://app.codecov.io/gh/svyatov/sec_id) [![CI](https://github.com/svyatov/sec_id/actions/workflows/main.yml/badge.svg?branch=main)](https://github.com/svyatov/sec_id/actions?query=workflow%3ACI) [![License](https://img.shields.io/github/license/svyatov/sec_id)](LICENSE.txt)
6
2
 
7
- Validate securities identification numbers with ease!
3
+ > Validate securities identification numbers with ease!
8
4
 
9
- Check-digit calculation is also available.
5
+ ## Table of Contents
10
6
 
11
- Currently supported standards:
12
- [ISIN](https://en.wikipedia.org/wiki/International_Securities_Identification_Number),
13
- [CUSIP](https://en.wikipedia.org/wiki/CUSIP),
14
- [SEDOL](https://en.wikipedia.org/wiki/SEDOL),
15
- [FIGI](https://en.wikipedia.org/wiki/Financial_Instrument_Global_Identifier),
16
- [CIK](https://en.wikipedia.org/wiki/Central_Index_Key).
7
+ - [Supported Ruby Versions](#supported-ruby-versions)
8
+ - [Installation](#installation)
9
+ - [Supported Standards and Usage](#supported-standards-and-usage)
10
+ - [ISIN](#isin) - International Securities Identification Number
11
+ - [CUSIP](#cusip) - Committee on Uniform Securities Identification Procedures
12
+ - [SEDOL](#sedol) - Stock Exchange Daily Official List
13
+ - [FIGI](#figi) - Financial Instrument Global Identifier
14
+ - [LEI](#lei) - Legal Entity Identifier
15
+ - [IBAN](#iban) - International Bank Account Number
16
+ - [CIK](#cik) - Central Index Key
17
+ - [OCC](#occ) - Options Clearing Corporation Symbol
18
+ - [Development](#development)
19
+ - [Contributing](#contributing)
20
+ - [License](#license)
17
21
 
18
- Work in progress:
19
- [IBAN](https://en.wikipedia.org/wiki/International_Bank_Account_Number).
22
+ ## Supported Ruby Versions
23
+
24
+ Ruby 3.1+ is required.
20
25
 
21
26
  ## Installation
22
27
 
23
28
  Add this line to your application's Gemfile:
24
29
 
25
30
  ```ruby
26
- gem 'sec_id', '~> 4.1'
31
+ gem 'sec_id', '~> 4.3'
27
32
  ```
28
33
 
29
34
  And then execute:
30
35
 
31
- $ bundle
32
-
33
- Or install it yourself as:
34
-
35
- $ gem install sec_id
36
-
37
- ## Usage
38
-
39
- ### Base API
40
-
41
- Base API has 4 main methods which can be used both on class level and on instance level:
42
-
43
- * `valid?` - never raises any errors, always returns `true` or `false`,
44
- numbers without the check-digit will return `false`
45
-
46
- ```ruby
47
- # class level
48
- SecId::ISIN.valid?('US5949181045') # => true
49
- SecId::ISIN.valid?('US594918104') # => false
50
-
51
- # instance level
52
- isin = SecId::ISIN.new('US5949181045')
53
- isin.valid? # => true
54
- ```
55
-
56
- * `valid_format?` - never raises any errors, always returns `true` or `false`,
57
- numbers without the check-digit but in valid format will return `true`
58
-
59
- ```ruby
60
- # class level
61
- SecId::ISIN.valid_format?('US5949181045') # => true
62
- SecId::ISIN.valid_format?('US594918104') # => true
63
-
64
- # instance level
65
- isin = SecId::ISIN.new('US594918104')
66
- isin.valid_format? # => true
67
- ```
36
+ ```bash
37
+ bundle install
38
+ ```
68
39
 
69
- * `restore!` - restores check-digit and returns the full number,
70
- raises an error if number's format is invalid and thus check-digit is impossible to calculate
40
+ Or install it yourself:
71
41
 
72
- ```ruby
73
- # class level
74
- SecId::ISIN.restore!('US594918104') # => 'US5949181045'
42
+ ```bash
43
+ gem install sec_id
44
+ ```
75
45
 
76
- # instance level
77
- isin = SecId::ISIN.new('US5949181045')
78
- isin.restore! # => 'US5949181045'
79
- ```
46
+ ## Supported Standards and Usage
80
47
 
81
- * `check_digit` and `calculate_check_digit` - these are the same,
82
- but the former is used at class level for bravity,
83
- and the latter is used at instance level for clarity;
84
- it calculates and returns the check-digit if the number is valid
85
- and raises an error otherwise.
48
+ All identifier classes provide `valid?` and `valid_format?` methods at both class and instance levels.
86
49
 
87
- ```ruby
88
- # class level
89
- SecId::ISIN.check_digit('US594918104') # => 5
50
+ **Check-digit based identifiers** (ISIN, CUSIP, SEDOL, FIGI, LEI, IBAN) also provide:
51
+ - `restore!` - restores check-digit and returns the full number
52
+ - `check_digit` / `calculate_check_digit` - calculates and returns the check-digit
90
53
 
91
- # instance level
92
- isin = SecId::ISIN.new('US594918104')
93
- isin.calculate_check_digit # => 5
94
- isin.check_digit # => nil
95
- ```
54
+ **Normalization based identifiers** (CIK, OCC) provide instead:
55
+ - `normalize!` - pads/formats the identifier to its standard form
96
56
 
97
- :exclamation: Please note that `isin.check_digit` returns `nil` because `#check_digit`
98
- at instance level represents original check-digit of the number passed to `new`,
99
- which in this example is missing and thus it's `nil`.
57
+ ### ISIN
100
58
 
101
- ### SecId::ISIN full example
59
+ > [International Securities Identification Number](https://en.wikipedia.org/wiki/International_Securities_Identification_Number) - a 12-character alphanumeric code that uniquely identifies a security.
102
60
 
103
61
  ```ruby
104
62
  # class level
@@ -120,14 +78,16 @@ isin.calculate_check_digit # => 5
120
78
  isin.to_cusip # => #<SecId::CUSIP>
121
79
  ```
122
80
 
123
- ### SecId::CUSIP full example
81
+ ### CUSIP
82
+
83
+ > [Committee on Uniform Securities Identification Procedures](https://en.wikipedia.org/wiki/CUSIP) - a 9-character alphanumeric code that identifies North American securities.
124
84
 
125
85
  ```ruby
126
86
  # class level
127
87
  SecId::CUSIP.valid?('594918104') # => true
128
88
  SecId::CUSIP.valid_format?('59491810') # => true
129
89
  SecId::CUSIP.restore!('59491810') # => '594918104'
130
- SecId::CUSIP.check_digit('59491810') # => 5
90
+ SecId::CUSIP.check_digit('59491810') # => 4
131
91
 
132
92
  # instance level
133
93
  cusip = SecId::CUSIP.new('594918104')
@@ -140,10 +100,12 @@ cusip.valid_format? # => true
140
100
  cusip.restore! # => '594918104'
141
101
  cusip.calculate_check_digit # => 4
142
102
  cusip.to_isin('US') # => #<SecId::ISIN>
143
- cusip.cins? # => true
103
+ cusip.cins? # => false
144
104
  ```
145
105
 
146
- ### SecId::SEDOL full example
106
+ ### SEDOL
107
+
108
+ > [Stock Exchange Daily Official List](https://en.wikipedia.org/wiki/SEDOL) - a 7-character alphanumeric code used in the United Kingdom and Ireland.
147
109
 
148
110
  ```ruby
149
111
  # class level
@@ -153,16 +115,18 @@ SecId::SEDOL.restore!('B0Z52W') # => 'B0Z52W5'
153
115
  SecId::SEDOL.check_digit('B0Z52W') # => 5
154
116
 
155
117
  # instance level
156
- cusip = SecId::SEDOL.new('B0Z52W5')
157
- cusip.full_number # => 'B0Z52W5'
158
- cusip.check_digit # => 5
159
- cusip.valid? # => true
160
- cusip.valid_format? # => true
161
- cusip.restore! # => 'B0Z52W5'
162
- cusip.calculate_check_digit # => 5
118
+ sedol = SecId::SEDOL.new('B0Z52W5')
119
+ sedol.full_number # => 'B0Z52W5'
120
+ sedol.check_digit # => 5
121
+ sedol.valid? # => true
122
+ sedol.valid_format? # => true
123
+ sedol.restore! # => 'B0Z52W5'
124
+ sedol.calculate_check_digit # => 5
163
125
  ```
164
126
 
165
- ### SecId::FIGI full example
127
+ ### FIGI
128
+
129
+ > [Financial Instrument Global Identifier](https://en.wikipedia.org/wiki/Financial_Instrument_Global_Identifier) - a 12-character alphanumeric code that provides unique identification of financial instruments.
166
130
 
167
131
  ```ruby
168
132
  # class level
@@ -183,25 +147,116 @@ figi.restore! # => 'BBG000DMBXR2'
183
147
  figi.calculate_check_digit # => 2
184
148
  ```
185
149
 
186
- ### SecId::CIK full example
150
+ ### LEI
151
+
152
+ > [Legal Entity Identifier](https://en.wikipedia.org/wiki/Legal_Entity_Identifier) - a 20-character alphanumeric code that uniquely identifies legal entities participating in financial transactions.
153
+
154
+ ```ruby
155
+ # class level
156
+ SecId::LEI.valid?('5493006MHB84DD0ZWV18') # => true
157
+ SecId::LEI.valid_format?('5493006MHB84DD0ZWV') # => true
158
+ SecId::LEI.restore!('5493006MHB84DD0ZWV') # => '5493006MHB84DD0ZWV18'
159
+ SecId::LEI.check_digit('5493006MHB84DD0ZWV') # => 18
160
+
161
+ # instance level
162
+ lei = SecId::LEI.new('5493006MHB84DD0ZWV18')
163
+ lei.full_number # => '5493006MHB84DD0ZWV18'
164
+ lei.lou_id # => '5493'
165
+ lei.reserved # => '00'
166
+ lei.entity_id # => '6MHB84DD0ZWV'
167
+ lei.check_digit # => 18
168
+ lei.valid? # => true
169
+ lei.valid_format? # => true
170
+ lei.restore! # => '5493006MHB84DD0ZWV18'
171
+ lei.calculate_check_digit # => 18
172
+ ```
173
+
174
+ ### IBAN
175
+
176
+ > [International Bank Account Number](https://en.wikipedia.org/wiki/International_Bank_Account_Number) - an internationally standardized system for identifying bank accounts across national borders (ISO 13616).
177
+
178
+ ```ruby
179
+ # class level
180
+ SecId::IBAN.valid?('DE89370400440532013000') # => true
181
+ SecId::IBAN.valid_format?('DE370400440532013000') # => true
182
+ SecId::IBAN.restore!('DE370400440532013000') # => 'DE89370400440532013000'
183
+ SecId::IBAN.check_digit('DE370400440532013000') # => 89
184
+
185
+ # instance level
186
+ iban = SecId::IBAN.new('DE89370400440532013000')
187
+ iban.full_number # => 'DE89370400440532013000'
188
+ iban.country_code # => 'DE'
189
+ iban.bban # => '370400440532013000'
190
+ iban.bank_code # => '37040044'
191
+ iban.account_number # => '0532013000'
192
+ iban.check_digit # => 89
193
+ iban.valid? # => true
194
+ iban.valid_format? # => true
195
+ iban.restore! # => 'DE89370400440532013000'
196
+ iban.calculate_check_digit # => 89
197
+ iban.known_country? # => true
198
+ ```
199
+
200
+ Full BBAN structural validation is supported for EU/EEA countries. Other countries have length-only validation.
201
+
202
+ ### CIK
203
+
204
+ > [Central Index Key](https://en.wikipedia.org/wiki/Central_Index_Key) - a 10-digit number used by the SEC to identify corporations and individuals who have filed disclosures.
187
205
 
188
206
  ```ruby
189
207
  # class level
190
208
  SecId::CIK.valid?('0001094517') # => true
191
209
  SecId::CIK.valid_format?('0001094517') # => true
192
- SecId::CIK.restore!('1094517') # => '0001094517'
193
- SecId::CIK.check_digit('0001094517') # raises NotImplementedError
210
+ SecId::CIK.normalize!('1094517') # => '0001094517'
194
211
 
195
212
  # instance level
196
213
  cik = SecId::CIK.new('0001094517')
197
- cik.full_number # => '0001094517'
198
- cik.padding # => '000'
199
- cik.identifier # => '1094517'
200
- cik.valid? # => true
201
- cik.valid_format? # => true
202
- cik.restore! # => '0001094517'
203
- cik.calculate_check_digit # raises NotImplementedError
204
- cik.check_digit # => nil
214
+ cik.full_number # => '0001094517'
215
+ cik.padding # => '000'
216
+ cik.identifier # => '1094517'
217
+ cik.valid? # => true
218
+ cik.valid_format? # => true
219
+ cik.normalize! # => '0001094517'
220
+ cik.to_s # => '0001094517'
221
+ ```
222
+
223
+ ### OCC
224
+
225
+ > [Options Clearing Corporation Symbol](https://en.wikipedia.org/wiki/Option_symbol#The_OCC_Option_Symbol) - a 21-character code used to identify equity options contracts.
226
+
227
+ ```ruby
228
+ # class level
229
+ SecId::OCC.valid?('BRKB 100417C00090000') # => true
230
+ SecId::OCC.valid_format?('BRKB 100417C00090000') # => true
231
+ SecId::OCC.normalize!('BRKB100417C00090000') # => 'BRKB 100417C00090000'
232
+ SecId::OCC.build(
233
+ underlying: 'BRKB',
234
+ date: Date.new(2010, 4, 17),
235
+ type: 'C',
236
+ strike: 90,
237
+ ) # => #<SecId::OCC>
238
+
239
+ # instance level
240
+ occ = SecId::OCC.new('BRKB 100417C00090000')
241
+ occ.full_symbol # => 'BRKB 100417C00090000'
242
+ occ.underlying # => 'BRKB'
243
+ occ.date_str # => '100417'
244
+ occ.date_obj # => #<Date: 2010-04-17>
245
+ occ.type # => 'C'
246
+ occ.strike # => 90.0
247
+ occ.valid? # => true
248
+ occ.valid_format? # => true
249
+ occ.normalize! # => 'BRKB 100417C00090000'
250
+
251
+ occ = SecId::OCC.new('BRKB 2010-04-17C00090000')
252
+ occ.valid_format? # => false
253
+ occ.normalize! # raises SecId::InvalidFormatError
254
+
255
+ occ = SecId::OCC.new('X 250620C00050000')
256
+ occ.full_symbol # => 'X 250620C00050000'
257
+ occ.valid? # => true
258
+ occ.normalize! # => 'X 250620C00050000'
259
+ occ.full_symbol # => 'X 250620C00050000'
205
260
  ```
206
261
 
207
262
  ## Development
data/lib/sec_id/base.rb CHANGED
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SecId
4
+ # Character-to-digit mapping for Luhn algorithm variants.
5
+ # Maps alphanumeric characters to digit arrays for multi-digit expansion.
6
+ # Used by ISIN for check-digit calculation.
4
7
  CHAR_TO_DIGITS = {
5
8
  '0' => 0, '1' => 1, '2' => 2, '3' => 3, '4' => 4,
6
9
  '5' => 5, '6' => 6, '7' => 7, '8' => 8, '9' => 9,
@@ -12,6 +15,9 @@ module SecId
12
15
  '*' => [3, 6], '@' => [3, 7], '#' => [3, 8]
13
16
  }.freeze
14
17
 
18
+ # Character-to-digit mapping for single-digit conversion.
19
+ # Maps alphanumeric characters to values 0-38 (A=10, B=11, ..., Z=35, *=36, @=37, #=38).
20
+ # Used by CUSIP, FIGI, SEDOL, LEI, and IBAN for check-digit calculations.
15
21
  CHAR_TO_DIGIT = {
16
22
  '0' => 0, '1' => 1, '2' => 2, '3' => 3, '4' => 4,
17
23
  '5' => 5, '6' => 6, '7' => 7, '8' => 8, '9' => 9,
@@ -23,50 +29,128 @@ module SecId
23
29
  '*' => 36, '@' => 37, '#' => 38
24
30
  }.freeze
25
31
 
32
+ # Base class for securities identifiers that provides a common interface
33
+ # for validation, check-digit calculation, and parsing.
34
+ #
35
+ # Subclasses must implement:
36
+ # - ID_REGEX constant with named capture groups for parsing
37
+ # - initialize method that calls parse and extracts components
38
+ # - calculate_check_digit method (only if has_check_digit? returns true)
39
+ #
40
+ # Subclasses may override:
41
+ # - has_check_digit? to return false for identifiers without check digits (e.g., CIK, OCC)
42
+ # - valid_format? for additional format validation beyond regex matching
43
+ # - to_s for custom string representation
44
+ #
45
+ # @example Implementing a check-digit identifier
46
+ # class MyIdentifier < Base
47
+ # ID_REGEX = /\A(?<identifier>[A-Z]{6})(?<check_digit>\d)?\z/x
48
+ #
49
+ # def initialize(id)
50
+ # parts = parse(id)
51
+ # @identifier = parts[:identifier]
52
+ # @check_digit = parts[:check_digit]&.to_i
53
+ # end
54
+ #
55
+ # def calculate_check_digit
56
+ # validate_format_for_calculation!
57
+ # mod10(some_algorithm)
58
+ # end
59
+ # end
60
+ #
61
+ # @example Implementing a non-check-digit identifier
62
+ # class SimpleId < Base
63
+ # def has_check_digit?
64
+ # false
65
+ # end
66
+ # end
26
67
  class Base
27
- attr_reader :full_number, :identifier, :check_digit
68
+ # @return [String] the original input after normalization (stripped and uppercased)
69
+ attr_reader :full_number
70
+
71
+ # @return [String, nil] the main identifier portion (without check digit)
72
+ attr_reader :identifier
73
+
74
+ # @return [Integer, nil] the check digit value
75
+ attr_reader :check_digit
28
76
 
29
77
  class << self
78
+ # @param id [String] the identifier to validate
79
+ # @return [Boolean]
30
80
  def valid?(id)
31
81
  new(id).valid?
32
82
  end
33
83
 
84
+ # @param id [String] the identifier to check
85
+ # @return [Boolean]
34
86
  def valid_format?(id)
35
87
  new(id).valid_format?
36
88
  end
37
89
 
90
+ # Restores (calculates) the check digit and returns the full identifier.
91
+ #
92
+ # @param id_without_check_digit [String] identifier without or with incorrect check digit
93
+ # @return [String] the full identifier with correct check digit
94
+ # @raise [InvalidFormatError] if the identifier format is invalid
38
95
  def restore!(id_without_check_digit)
39
96
  new(id_without_check_digit).restore!
40
97
  end
41
98
 
99
+ # @param id [String] the identifier to calculate check digit for
100
+ # @return [Integer] the calculated check digit
101
+ # @raise [InvalidFormatError] if the identifier format is invalid
42
102
  def check_digit(id)
43
103
  new(id).calculate_check_digit
44
104
  end
45
105
  end
46
106
 
107
+ # Subclasses must override this method.
108
+ #
109
+ # @param _sec_id_number [String] the identifier string to parse
110
+ # @raise [NotImplementedError] always raised in base class
47
111
  def initialize(_sec_id_number)
48
112
  raise NotImplementedError
49
113
  end
50
114
 
115
+ # Override in subclasses to return false for identifiers without check digits.
116
+ #
117
+ # @return [Boolean]
118
+ def has_check_digit?
119
+ true
120
+ end
121
+
122
+ # @return [Boolean]
51
123
  def valid?
124
+ return valid_format? unless has_check_digit?
52
125
  return false unless valid_format?
53
126
 
54
127
  check_digit == calculate_check_digit
55
128
  end
56
129
 
130
+ # Override in subclasses for additional format validation.
131
+ #
132
+ # @return [Boolean]
57
133
  def valid_format?
58
- identifier ? true : false
134
+ !identifier.nil?
59
135
  end
60
136
 
137
+ # @return [String] the full identifier with correct check digit
138
+ # @raise [InvalidFormatError] if the identifier format is invalid
61
139
  def restore!
62
140
  @check_digit = calculate_check_digit
63
141
  @full_number = to_s
64
142
  end
65
143
 
144
+ # Subclasses must override this method.
145
+ #
146
+ # @return [Integer] the calculated check digit
147
+ # @raise [NotImplementedError] always raised in base class
148
+ # @raise [InvalidFormatError] if the identifier format is invalid (in subclasses)
66
149
  def calculate_check_digit
67
150
  raise NotImplementedError
68
151
  end
69
152
 
153
+ # @return [String]
70
154
  def to_s
71
155
  "#{identifier}#{check_digit}"
72
156
  end
@@ -74,29 +158,57 @@ module SecId
74
158
 
75
159
  private
76
160
 
77
- def id_digits
78
- raise NotImplementedError
161
+ # @raise [InvalidFormatError] if valid_format? returns false
162
+ # @return [void]
163
+ def validate_format_for_calculation!
164
+ return if valid_format?
165
+
166
+ raise InvalidFormatError, "#{self.class.name} '#{full_number}' is invalid and check-digit cannot be calculated!"
79
167
  end
80
168
 
81
- def parse(sec_id_number)
82
- @full_number = sec_id_number.to_s.strip.upcase
169
+ # @param sec_id_number [String, #to_s] the identifier to parse
170
+ # @param upcase [Boolean] whether to upcase the input
171
+ # @return [MatchData, Hash] the regex match data or empty hash if no match
172
+ def parse(sec_id_number, upcase: true)
173
+ @full_number = sec_id_number.to_s.strip
174
+ @full_number.upcase! if upcase
83
175
  @full_number.match(self.class::ID_REGEX) || {}
84
176
  end
85
177
 
178
+ # @return [Array<Integer>] array of digit values
179
+ # @raise [NotImplementedError] always raised in base class
180
+ def id_digits
181
+ raise NotImplementedError
182
+ end
183
+
184
+ # @param char [String] single character to convert
185
+ # @return [Integer, Array<Integer>] single digit or array of digits
86
186
  def char_to_digits(char)
87
187
  SecId::CHAR_TO_DIGITS.fetch(char)
88
188
  end
89
189
 
190
+ # @param char [String] single character to convert
191
+ # @return [Integer] numeric value (0-38)
90
192
  def char_to_digit(char)
91
193
  SecId::CHAR_TO_DIGIT.fetch(char)
92
194
  end
93
195
 
196
+ # @param sum [Integer] the sum to calculate check digit from
197
+ # @return [Integer] check digit (0-9)
94
198
  def mod10(sum)
95
199
  (10 - (sum % 10)) % 10
96
200
  end
97
201
 
202
+ # @param number [Integer] number to split
203
+ # @return [Integer] sum of tens and units digits
98
204
  def div10mod10(number)
99
205
  (number / 10) + (number % 10)
100
206
  end
207
+
208
+ # @param numeric_string [String] numeric string representation
209
+ # @return [Integer] check digit value (1-98)
210
+ def mod97(numeric_string)
211
+ 98 - (numeric_string.to_i % 97)
212
+ end
101
213
  end
102
214
  end