sec_id 6.0.0 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/MIGRATION.md +147 -0
- data/README.md +116 -44
- data/lib/sec_id/active_model.rb +1 -1
- data/lib/sec_id/base.rb +63 -14
- data/lib/sec_id/cei.rb +7 -7
- data/lib/sec_id/cik.rb +1 -1
- data/lib/sec_id/concerns/checkable.rb +90 -46
- data/lib/sec_id/concerns/generatable.rb +5 -5
- data/lib/sec_id/concerns/normalizable.rb +4 -4
- data/lib/sec_id/concerns/validatable.rb +7 -7
- data/lib/sec_id/cusip.rb +8 -8
- data/lib/sec_id/deprecation.rb +26 -0
- data/lib/sec_id/detector.rb +3 -26
- data/lib/sec_id/dti.rb +8 -14
- data/lib/sec_id/figi.rb +9 -9
- data/lib/sec_id/iban.rb +20 -20
- data/lib/sec_id/isin.rb +9 -9
- data/lib/sec_id/lei.rb +10 -10
- data/lib/sec_id/occ.rb +1 -1
- data/lib/sec_id/scanner.rb +17 -22
- data/lib/sec_id/sedol.rb +11 -11
- data/lib/sec_id/upi.rb +76 -0
- data/lib/sec_id/valoren.rb +2 -2
- data/lib/sec_id/version.rb +1 -1
- data/lib/sec_id/wkn.rb +1 -1
- data/lib/sec_id.rb +11 -6
- data/sec_id.gemspec +2 -2
- data/sig/sec_id/base.rbs +15 -4
- data/sig/sec_id/cei.rbs +4 -0
- data/sig/sec_id/concerns/checkable.rbs +17 -7
- data/sig/sec_id/concerns/generatable.rbs +1 -1
- data/sig/sec_id/concerns/validatable.rbs +1 -1
- data/sig/sec_id/cusip.rbs +4 -0
- data/sig/sec_id/deprecation.rbs +6 -0
- data/sig/sec_id/detector.rbs +0 -6
- data/sig/sec_id/dti.rbs +6 -1
- data/sig/sec_id/figi.rbs +4 -0
- data/sig/sec_id/iban.rbs +7 -3
- data/sig/sec_id/isin.rbs +4 -0
- data/sig/sec_id/lei.rbs +5 -1
- data/sig/sec_id/scanner.rbs +2 -4
- data/sig/sec_id/sedol.rbs +4 -0
- data/sig/sec_id/upi.rbs +29 -0
- data/sig/sec_id.rbs +5 -2
- metadata +7 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b89119bace7665d5cbf511fc3ddf23083e375f7b8eff25dea56b00b584868e20
|
|
4
|
+
data.tar.gz: a9bb04a1ae20c48c150b2775f55d8f262707b7c6a8510283d6896b8b78dc1876
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3f12de747aefe8f567c429c79c05717eb6a7425ffa644325b82b7255df822a5f09158afb8ac34565bfb642a9d5f6fd01effca91d7f0274e0abe5eb2fed0dd28e
|
|
7
|
+
data.tar.gz: 6d82e962ddb3219c6d5dbb28dcbfc188f56773b9210b83c6db9625bef83e6a50852a8d316166f3fe801d3c201b6654fd8d0f252da46593823edb1369e2bda8a3
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,27 @@ and [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/).
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [7.0.0] - 2026-07-14
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- UPI (ISO 4914, Unique Product Identifier) support via `SecID::UPI` — the gem's 16th identifier type, and the first offline UPI validator in any language. Validates the 12-character code (fixed `QZ` prefix, 9-character body, 1 check character) issued by the ANNA Derivatives Service Bureau for OTC-derivatives reporting (CFTC, EMIR, and other global mandates). The check character is computed fully offline via ISO 7064 hybrid MOD 31,30 over the same 30-symbol alphabet as DTI (digits plus consonants; vowels and `Y` never appear), pinned empirically against DSB-issued vectors — no DSB registry lookup or paywalled ISO 4914 spec required. Like DTI, `checksum`/`calculate_checksum` return a `String` rather than an `Integer`, since UPI check characters can be letters. A UPI shares the 12-character length bucket with ISIN and can double-detect (ISIN ranked first) when its digit check character also satisfies ISIN's Luhn checksum.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- BREAKING: Renamed the check-digit concept to **checksum** across the entire public API, because the old name was wrong on two axes — DTI's and UPI's check value is a `String` (it can be a letter, not a digit), and LEI and IBAN carry a two-character check value. The instance and class methods `check_digit` → `checksum`, `calculate_check_digit` → `calculate_checksum`, and `has_check_digit?` → `has_checksum?`; the error class `SecID::InvalidCheckDigitError` → `SecID::InvalidChecksumError`; the error code `:invalid_check_digit` → `:invalid_checksum` (a **hard flip with no dual emission** — dual would duplicate `errors.details` entries, so update any `errors.details`/`explain`/ActiveModel `details: true` matcher immediately); and the `:check_digit` components key → `:checksum` in `to_h`/`deconstruct_keys` across all nine checkable types. `restore`/`restore!` and the `Checkable` concern name are unchanged. No validation or checksum-arithmetic behavior changed: every identifier that was valid stays valid and every computed value is byte-identical. The old method names, the `InvalidCheckDigitError` constant, and the `:check_digit` components key remain as deprecated bridges through v7 (removed in v8); the error code is the only surface with no bridge. See MIGRATION.md for the full upgrade guide
|
|
20
|
+
|
|
21
|
+
### Deprecated
|
|
22
|
+
|
|
23
|
+
- The pre-rename check-digit names, kept as v7 bridges and removed in v8: the `check_digit` / `calculate_check_digit` / `has_check_digit?` methods (instance and class level) — each warns via `Kernel#warn` on every call, visible at Ruby's default verbosity and silenceable with `-W0` / `$VERBOSE = nil` or an app-level `Warning` override; the `SecID::InvalidCheckDigitError` constant, a same-object alias of `InvalidChecksumError` so `rescue` under either name keeps working; and the `:check_digit` key, still present alongside `:checksum` in `components` / `to_h` / `deconstruct_keys`
|
|
24
|
+
|
|
25
|
+
## [6.1.0] - 2026-07-10
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- `SecID::Base#deconstruct_keys` — every identifier now destructures in `case/in` patterns, exposing the same parsed fields `#to_h` reports under `:components`. The `keys` argument is ignored, no new keys are introduced, and validity is not part of the protocol: `SecID.parse` returning `nil` remains the validity guard, and an instance built from unparseable input binds `nil` for each component. No `deconstruct` (array-pattern) method is defined
|
|
30
|
+
- `SecID::Base.type_key` — the memoized class-level registry symbol for an identifier type, joining the existing `short_name` / `full_name` / `id_length` metadata surface. It round-trips through the registry: `SecID[SecID::ISIN.type_key] == SecID::ISIN`. It is now the single authority for that symbol; the registry, `#to_h`, `SecID.explain`, the ActiveModel validator, the detector, and the scanner all read it instead of each deriving it from the class name
|
|
31
|
+
|
|
11
32
|
## [6.0.0] - 2026-07-08
|
|
12
33
|
|
|
13
34
|
### Added
|
data/MIGRATION.md
CHANGED
|
@@ -1,3 +1,150 @@
|
|
|
1
|
+
# Upgrading to SecID 7.0
|
|
2
|
+
|
|
3
|
+
This guide covers all breaking changes when upgrading from SecID 6.x to 7.0. They are all one
|
|
4
|
+
mechanical rename: the **check-digit** concept became **checksum** across the public API —
|
|
5
|
+
methods, the error class, the error code, the components key, and documentation vocabulary.
|
|
6
|
+
The name was inaccurate on two axes: DTI's and UPI's check value is a `String` (it can be a
|
|
7
|
+
letter, not a digit), and LEI and IBAN carry a two-character check value. `checksum` is
|
|
8
|
+
type- and count-agnostic.
|
|
9
|
+
|
|
10
|
+
Nothing about validation or checksum arithmetic changed — every previously valid identifier is
|
|
11
|
+
still valid and every computed value is byte-identical. Only names, one error code, and
|
|
12
|
+
deprecation warnings changed.
|
|
13
|
+
|
|
14
|
+
## Quick Reference
|
|
15
|
+
|
|
16
|
+
| What changed | Before (6.x) | After (7.0) |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| Instance/class method | `isin.check_digit`, `SecID::ISIN.check_digit(id)` | `isin.checksum`, `SecID::ISIN.checksum(id)` |
|
|
19
|
+
| Calculation method | `isin.calculate_check_digit` | `isin.calculate_checksum` |
|
|
20
|
+
| Capability predicate | `SecID::ISIN.has_check_digit?` | `SecID::ISIN.has_checksum?` |
|
|
21
|
+
| Error class | `SecID::InvalidCheckDigitError` | `SecID::InvalidChecksumError` |
|
|
22
|
+
| **Error code (hard flip)** | `:invalid_check_digit` | `:invalid_checksum` |
|
|
23
|
+
| Components / `to_h` / pattern-match key | `{ check_digit: … }` | `{ checksum: … }` |
|
|
24
|
+
|
|
25
|
+
`restore` / `restore!` and the `Checkable` concern name are unchanged.
|
|
26
|
+
|
|
27
|
+
**Bridge:** the old method names, the `InvalidCheckDigitError` constant, and the `:check_digit`
|
|
28
|
+
components key all keep working through v7 (the methods warn on use); they are removed in v8.
|
|
29
|
+
**The one exception is the error code** — `:invalid_check_digit` flips hard to `:invalid_checksum`
|
|
30
|
+
at v7 with no bridge, so an error-code matcher must be updated immediately.
|
|
31
|
+
|
|
32
|
+
## Step-by-Step
|
|
33
|
+
|
|
34
|
+
### 1. Update Gemfile
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
gem 'sec_id', '~> 7.0'
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Then run `bundle update sec_id`.
|
|
41
|
+
|
|
42
|
+
### 2. Rename method calls (deprecated aliases warn through v7)
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
# Before (6.x)
|
|
46
|
+
isin.check_digit
|
|
47
|
+
isin.calculate_check_digit
|
|
48
|
+
SecID::ISIN.check_digit('US594918104')
|
|
49
|
+
SecID::ISIN.has_check_digit?
|
|
50
|
+
|
|
51
|
+
# After (7.0)
|
|
52
|
+
isin.checksum
|
|
53
|
+
isin.calculate_checksum
|
|
54
|
+
SecID::ISIN.checksum('US594918104')
|
|
55
|
+
SecID::ISIN.has_checksum?
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The old names still work through v7 but emit a deprecation warning on every call and are removed
|
|
59
|
+
in v8. See step 6 to silence them if you cannot migrate every call site yet.
|
|
60
|
+
|
|
61
|
+
### 3. Rename the rescued error class
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
# Before (6.x)
|
|
65
|
+
rescue SecID::InvalidCheckDigitError => e
|
|
66
|
+
|
|
67
|
+
# After (7.0)
|
|
68
|
+
rescue SecID::InvalidChecksumError => e
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`SecID::InvalidCheckDigitError` remains as a constant alias of `SecID::InvalidChecksumError`
|
|
72
|
+
through v7 (it is the *same class object*, so `rescue` under either name catches the same error),
|
|
73
|
+
and is removed in v8.
|
|
74
|
+
|
|
75
|
+
The constant alias emits **no** deprecation warning at runtime (it is a plain constant, not a
|
|
76
|
+
warned method), so `-W` output won't reveal your rescue sites — find them statically:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
grep -rEn 'InvalidCheckDigitError' app/ lib/ spec/
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### 4. Update error-code matchers (hard flip — no bridge)
|
|
83
|
+
|
|
84
|
+
The `:invalid_check_digit` code in `errors.details` and `explain` output is replaced by
|
|
85
|
+
`:invalid_checksum` with **no dual emission** — emitting both would duplicate `errors.details`
|
|
86
|
+
entries. This is the only change with no v7 bridge, so update any matcher immediately.
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
# Before (6.x)
|
|
90
|
+
isin.errors.details.first[:error] == :invalid_check_digit
|
|
91
|
+
SecID.explain('US5949181040', types: [:isin])[:candidates].first[:errors]
|
|
92
|
+
# => [{ error: :invalid_check_digit, message: "Check digit '0' is invalid, expected '5'" }]
|
|
93
|
+
|
|
94
|
+
# After (7.0)
|
|
95
|
+
isin.errors.details.first[:error] == :invalid_checksum
|
|
96
|
+
SecID.explain('US5949181040', types: [:isin])[:candidates].first[:errors]
|
|
97
|
+
# => [{ error: :invalid_checksum, message: "Checksum '0' is invalid, expected '5'" }]
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
If you use the opt-in ActiveModel/Rails validator with `details: true`, the surfaced reason
|
|
101
|
+
follows the same flip: an invalid checksum now reports `:invalid_checksum`.
|
|
102
|
+
|
|
103
|
+
### 5. Migrate pattern matches and `to_h` readers before v8
|
|
104
|
+
|
|
105
|
+
`components` (and therefore `to_h` and `deconstruct_keys`) carries **both** `:checksum` and the
|
|
106
|
+
deprecated `:check_digit` key through v7, so existing pattern matches keep working. The
|
|
107
|
+
`:check_digit` key is removed in v8 — migrate to `:checksum` before then.
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
# Both work through v7, binding the same value:
|
|
111
|
+
isin => { check_digit: } # deprecated, removed in v8
|
|
112
|
+
isin => { checksum: } # canonical
|
|
113
|
+
|
|
114
|
+
isin.to_h[:components]
|
|
115
|
+
# => { country_code: 'US', nsin: '594918104', checksum: 5, check_digit: 5 }
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Reading the `:check_digit` key also emits **no** warning (the value mirrors `:checksum`), so grep
|
|
119
|
+
for the call sites rather than relying on stderr:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
grep -rEn ':check_digit\b|check_digit:' app/ lib/ spec/
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### 6. Silencing the deprecation warnings
|
|
126
|
+
|
|
127
|
+
The method aliases warn via `Kernel#warn` on every call, visible at Ruby's default verbosity.
|
|
128
|
+
If you cannot migrate every call site before v7, silence them one of these ways:
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
# Process-wide, at the lowest verbosity (also silences other Ruby warnings):
|
|
132
|
+
$VERBOSE = nil # equivalent to running with -W0
|
|
133
|
+
|
|
134
|
+
# Or filter only SecID's deprecations with an app-level Warning override:
|
|
135
|
+
module SecIDDeprecationSilencer
|
|
136
|
+
def warn(message, category: nil, **)
|
|
137
|
+
super unless message.to_s.include?('SecID: `')
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
Warning.extend(SecIDDeprecationSilencer)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The warnings are intentionally on by default (not routed through `Warning[:deprecated]`, which
|
|
144
|
+
Ruby leaves off) so the migration signal is visible without opting in.
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
1
148
|
# Upgrading to SecID 6.0
|
|
2
149
|
|
|
3
150
|
This guide covers all breaking changes when upgrading from SecID 5.x to 6.0. Every one is
|
data/README.md
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
- [Text Scanning](#text-scanning) - find identifiers in freeform text
|
|
12
12
|
- [Debugging Detection](#debugging-detection) - understand why strings match or don't
|
|
13
13
|
- [Structured Validation](#structured-validation) - detailed error codes and messages
|
|
14
|
+
- [Pattern Matching](#pattern-matching) - destructure identifiers with `case/in`
|
|
14
15
|
- [Generating Test Fixtures](#generating-test-fixtures) - produce valid identifiers for tests
|
|
15
16
|
- [ISIN](#isin) - International Securities Identification Number
|
|
16
17
|
- [CUSIP](#cusip) - Committee on Uniform Securities Identification Procedures
|
|
@@ -27,6 +28,7 @@
|
|
|
27
28
|
- [FISN](#fisn) - Financial Instrument Short Name
|
|
28
29
|
- [BIC](#bic) - Business Identifier Code / SWIFT code
|
|
29
30
|
- [DTI](#dti) - Digital Token Identifier
|
|
31
|
+
- [UPI](#upi) - Unique Product Identifier
|
|
30
32
|
- [ActiveModel / Rails Validator](#activemodel--rails-validator) - declarative `validates :isin, sec_id: {...}`
|
|
31
33
|
- [Lookup Service Integration](#lookup-service-integration)
|
|
32
34
|
- [Type Signatures (RBS)](#type-signatures-rbs)
|
|
@@ -45,7 +47,7 @@ Ruby 3.2+ is required.
|
|
|
45
47
|
Add this line to your application's Gemfile:
|
|
46
48
|
|
|
47
49
|
```ruby
|
|
48
|
-
gem 'sec_id', '~>
|
|
50
|
+
gem 'sec_id', '~> 7.0'
|
|
49
51
|
```
|
|
50
52
|
|
|
51
53
|
And then execute:
|
|
@@ -75,15 +77,16 @@ All identifier classes provide `valid?`, `errors`, `validate`, `validate!` metho
|
|
|
75
77
|
**All identifiers** support hash serialization:
|
|
76
78
|
- `#to_h` - returns a hash with `:type`, `:full_id`, `:normalized`, `:valid`, and `:components` keys
|
|
77
79
|
- `#as_json` - same as `#to_h`, for JSON serialization compatibility (Rails, `JSON.generate`, etc.)
|
|
80
|
+
- `#deconstruct_keys` - exposes `:components` for [pattern matching](#pattern-matching) with `case/in`
|
|
78
81
|
|
|
79
82
|
```ruby
|
|
80
83
|
SecID::ISIN.new('US5949181045').to_h
|
|
81
84
|
# => { type: :isin, full_id: 'US5949181045', normalized: 'US5949181045',
|
|
82
|
-
# valid: true, components: { country_code: 'US', nsin: '594918104',
|
|
85
|
+
# valid: true, components: { country_code: 'US', nsin: '594918104', checksum: 5 } }
|
|
83
86
|
|
|
84
87
|
SecID::ISIN.new('INVALID').to_h
|
|
85
88
|
# => { type: :isin, full_id: 'INVALID', normalized: nil,
|
|
86
|
-
# valid: false, components: { country_code: nil, nsin: nil,
|
|
89
|
+
# valid: false, components: { country_code: nil, nsin: nil, checksum: nil } }
|
|
87
90
|
```
|
|
88
91
|
|
|
89
92
|
**All identifiers** support value equality — two instances of the same type with the same normalized form are equal:
|
|
@@ -100,10 +103,10 @@ a.eql?(b) # => true
|
|
|
100
103
|
Set.new([a, b]).size # => 1
|
|
101
104
|
```
|
|
102
105
|
|
|
103
|
-
**
|
|
104
|
-
- `restore` / `.restore` - returns the full identifier string with correct
|
|
105
|
-
- `restore!` / `.restore!` - restores
|
|
106
|
-
- `
|
|
106
|
+
**Checksum-based identifiers** (ISIN, CUSIP, CEI, SEDOL, FIGI, LEI, IBAN, DTI, UPI) also provide:
|
|
107
|
+
- `restore` / `.restore` - returns the full identifier string with correct checksum (no mutation)
|
|
108
|
+
- `restore!` / `.restore!` - restores checksum in place and returns `self` / instance
|
|
109
|
+
- `checksum` / `calculate_checksum` - calculates and returns the checksum
|
|
107
110
|
|
|
108
111
|
### Metadata Registry
|
|
109
112
|
|
|
@@ -119,18 +122,19 @@ SecID.identifiers # => [SecID::ISIN, SecID::CUSIP, ...]
|
|
|
119
122
|
SecID.identifiers.map(&:short_name) # => ["ISIN", "CUSIP", "SEDOL", ...]
|
|
120
123
|
|
|
121
124
|
# Query metadata
|
|
125
|
+
SecID::ISIN.type_key # => :isin (SecID[SecID::ISIN.type_key] == SecID::ISIN)
|
|
122
126
|
SecID::ISIN.short_name # => "ISIN"
|
|
123
127
|
SecID::ISIN.full_name # => "International Securities Identification Number"
|
|
124
128
|
SecID::ISIN.id_length # => 12
|
|
125
129
|
SecID::ISIN.example # => "US5949181045"
|
|
126
|
-
SecID::ISIN.
|
|
130
|
+
SecID::ISIN.has_checksum? # => true
|
|
127
131
|
|
|
128
132
|
# Filter with standard Ruby
|
|
129
|
-
SecID.identifiers.select(&:
|
|
130
|
-
# => ["ISIN", "CUSIP", "SEDOL", "FIGI", "LEI", "IBAN", "CEI", "DTI"]
|
|
133
|
+
SecID.identifiers.select(&:has_checksum?).map(&:short_name)
|
|
134
|
+
# => ["ISIN", "CUSIP", "SEDOL", "FIGI", "LEI", "IBAN", "CEI", "DTI", "UPI"]
|
|
131
135
|
|
|
132
136
|
# Detect identifier type from an unknown string
|
|
133
|
-
# Results are sorted by specificity:
|
|
137
|
+
# Results are sorted by specificity: checksum types first, then by length precision
|
|
134
138
|
SecID.detect('US5949181045') # => [:isin]
|
|
135
139
|
SecID.detect('037833100') # => [:cusip, :valoren, :cik]
|
|
136
140
|
SecID.detect('APPLE INC/SH') # => [:fisn]
|
|
@@ -199,7 +203,7 @@ Understand why a string matches or doesn't match specific identifier types:
|
|
|
199
203
|
result = SecID.explain('US5949181040')
|
|
200
204
|
isin = result[:candidates].find { |c| c[:type] == :isin }
|
|
201
205
|
isin[:valid] # => false
|
|
202
|
-
isin[:errors].first[:error] # => :
|
|
206
|
+
isin[:errors].first[:error] # => :invalid_checksum
|
|
203
207
|
|
|
204
208
|
# Filter to specific types
|
|
205
209
|
SecID.explain('US5949181045', types: %i[isin cusip])
|
|
@@ -212,8 +216,8 @@ All identifier classes provide a Rails-like `#errors` API for detailed error rep
|
|
|
212
216
|
```ruby
|
|
213
217
|
isin = SecID::ISIN.new('US5949181040')
|
|
214
218
|
isin.errors.none? # => false
|
|
215
|
-
isin.errors.messages # => ["
|
|
216
|
-
isin.errors.details # => [{ error: :
|
|
219
|
+
isin.errors.messages # => ["Checksum '0' is invalid, expected '5'"]
|
|
220
|
+
isin.errors.details # => [{ error: :invalid_checksum, message: "Checksum '0' is invalid, expected '5'" }]
|
|
217
221
|
isin.errors.any? # => true
|
|
218
222
|
isin.errors.empty? # => false
|
|
219
223
|
isin.errors.size # => 1
|
|
@@ -230,7 +234,7 @@ SecID::ISIN.validate('US5949181040').errors # => #<SecID::Errors>
|
|
|
230
234
|
- `:invalid_format` - correct length and characters but wrong structure
|
|
231
235
|
|
|
232
236
|
**Type-specific error codes:**
|
|
233
|
-
- `:
|
|
237
|
+
- `:invalid_checksum` - checksum mismatch (ISIN, CUSIP, SEDOL, FIGI, LEI, IBAN, CEI, DTI, UPI)
|
|
234
238
|
- `:invalid_prefix` - restricted FIGI prefix (FIGI)
|
|
235
239
|
- `:invalid_category` - unknown CFI category code (CFI)
|
|
236
240
|
- `:invalid_group` - unknown CFI group code for category (CFI)
|
|
@@ -252,7 +256,7 @@ SecID::ISIN.new('INVALID').validate!
|
|
|
252
256
|
# => SecID::InvalidFormatError: Expected 12 characters, got 7
|
|
253
257
|
|
|
254
258
|
SecID::ISIN.new('US5949181040').validate!
|
|
255
|
-
# => SecID::
|
|
259
|
+
# => SecID::InvalidChecksumError: Checksum '0' is invalid, expected '5'
|
|
256
260
|
|
|
257
261
|
SecID::FIGI.new('BSG000BLNNH6').validate!
|
|
258
262
|
# => SecID::InvalidStructureError: Prefix 'BS' is restricted
|
|
@@ -261,9 +265,51 @@ SecID::FIGI.new('BSG000BLNNH6').validate!
|
|
|
261
265
|
isin = SecID::ISIN.validate!('US5949181045') # => #<SecID::ISIN>
|
|
262
266
|
```
|
|
263
267
|
|
|
268
|
+
### Pattern Matching
|
|
269
|
+
|
|
270
|
+
Every identifier destructures in `case/in` via its parsed components. Since `SecID.parse` returns `nil` for
|
|
271
|
+
anything invalid, an `in nil` branch is a complete validity guard:
|
|
272
|
+
|
|
273
|
+
```ruby
|
|
274
|
+
case SecID.parse(input)
|
|
275
|
+
in SecID::ISIN[country_code: 'US', nsin:] then "US security #{nsin}"
|
|
276
|
+
in SecID::ISIN[country_code:] then "foreign security from #{country_code}"
|
|
277
|
+
in SecID::OCC[underlying:, type:, strike_mills:] then "#{type} option on #{underlying}"
|
|
278
|
+
in nil then 'not a valid identifier'
|
|
279
|
+
end
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
`SecID::Match` is a `Data`, so scan results destructure one layer up and nest into the identifier they wrap:
|
|
283
|
+
|
|
284
|
+
```ruby
|
|
285
|
+
SecID.extract('AAPL ISIN US0378331005').each do |match|
|
|
286
|
+
case match
|
|
287
|
+
in { type: :isin, identifier: SecID::ISIN[country_code: 'US'] } then puts 'US ISIN found'
|
|
288
|
+
else next
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
The keys are exactly the `:components` of `#to_h`, and destructuring never consults
|
|
294
|
+
`valid?`. An instance built directly from unparseable input therefore binds `nil` for every component, the same shape
|
|
295
|
+
`to_h` reports:
|
|
296
|
+
|
|
297
|
+
```ruby
|
|
298
|
+
SecID::ISIN.new('GARBAGE') => { country_code:, nsin: }
|
|
299
|
+
country_code # => nil
|
|
300
|
+
nsin # => nil
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
CIK, WKN, and Valoren have no sub-fields: they match a bare `in SecID::CIK` but no keyed pattern. Array patterns
|
|
304
|
+
(`deconstruct`) are not supported.
|
|
305
|
+
|
|
306
|
+
Watch the `:type` key: on a `SecID::Match` it is the registry symbol (`:occ`), while on an `OCC` instance it is the
|
|
307
|
+
OSI option type (`'C'` or `'P'`), so `in SecID::OCC[type: :occ]` never matches. Other types have no `:type` component
|
|
308
|
+
at all — `#to_h`'s envelope keys (`:type`, `:full_id`, `:normalized`, `:valid`) are not part of the pattern surface.
|
|
309
|
+
|
|
264
310
|
### Generating Test Fixtures
|
|
265
311
|
|
|
266
|
-
Generate syntactically valid identifiers — with correct
|
|
312
|
+
Generate syntactically valid identifiers — with correct checksum where applicable — for use as test fixtures. Available per class and via the central dispatcher:
|
|
267
313
|
|
|
268
314
|
```ruby
|
|
269
315
|
SecID::ISIN.generate # => #<SecID::ISIN ...>
|
|
@@ -291,18 +337,18 @@ SecID::LEI.generate(random: Random.new(42)) == SecID::LEI.generate(random: Rando
|
|
|
291
337
|
SecID::ISIN.valid?('US5949181045') # => true
|
|
292
338
|
SecID::ISIN.restore('US594918104') # => 'US5949181045'
|
|
293
339
|
SecID::ISIN.restore!('US594918104') # => #<SecID::ISIN>
|
|
294
|
-
SecID::ISIN.
|
|
340
|
+
SecID::ISIN.checksum('US594918104') # => 5
|
|
295
341
|
|
|
296
342
|
# instance level
|
|
297
343
|
isin = SecID::ISIN.new('US5949181045')
|
|
298
344
|
isin.full_id # => 'US5949181045'
|
|
299
345
|
isin.country_code # => 'US'
|
|
300
346
|
isin.nsin # => '594918104'
|
|
301
|
-
isin.
|
|
347
|
+
isin.checksum # => 5
|
|
302
348
|
isin.valid? # => true
|
|
303
349
|
isin.restore # => 'US5949181045'
|
|
304
350
|
isin.restore! # => #<SecID::ISIN> (mutates instance)
|
|
305
|
-
isin.
|
|
351
|
+
isin.calculate_checksum # => 5
|
|
306
352
|
isin.to_pretty_s # => 'US 594918104 5'
|
|
307
353
|
isin.to_cusip # => #<SecID::CUSIP>
|
|
308
354
|
isin.nsin_type # => :cusip
|
|
@@ -336,18 +382,18 @@ SecID::ISIN.new('CH0012221716').to_valoren # => #<SecID::Valoren>
|
|
|
336
382
|
SecID::CUSIP.valid?('594918104') # => true
|
|
337
383
|
SecID::CUSIP.restore('59491810') # => '594918104'
|
|
338
384
|
SecID::CUSIP.restore!('59491810') # => #<SecID::CUSIP>
|
|
339
|
-
SecID::CUSIP.
|
|
385
|
+
SecID::CUSIP.checksum('59491810') # => 4
|
|
340
386
|
|
|
341
387
|
# instance level
|
|
342
388
|
cusip = SecID::CUSIP.new('594918104')
|
|
343
389
|
cusip.full_id # => '594918104'
|
|
344
390
|
cusip.cusip6 # => '594918'
|
|
345
391
|
cusip.issue # => '10'
|
|
346
|
-
cusip.
|
|
392
|
+
cusip.checksum # => 4
|
|
347
393
|
cusip.valid? # => true
|
|
348
394
|
cusip.restore # => '594918104'
|
|
349
395
|
cusip.restore! # => #<SecID::CUSIP> (mutates instance)
|
|
350
|
-
cusip.
|
|
396
|
+
cusip.calculate_checksum # => 4
|
|
351
397
|
cusip.to_pretty_s # => '594918 10 4'
|
|
352
398
|
cusip.to_isin('US') # => #<SecID::ISIN>
|
|
353
399
|
cusip.cins? # => false
|
|
@@ -362,7 +408,7 @@ cusip.cins? # => false
|
|
|
362
408
|
SecID::CEI.valid?('A0BCDEFGH1') # => true
|
|
363
409
|
SecID::CEI.restore('A0BCDEFGH') # => 'A0BCDEFGH1'
|
|
364
410
|
SecID::CEI.restore!('A0BCDEFGH') # => #<SecID::CEI>
|
|
365
|
-
SecID::CEI.
|
|
411
|
+
SecID::CEI.checksum('A0BCDEFGH') # => 1
|
|
366
412
|
|
|
367
413
|
# instance level
|
|
368
414
|
cei = SecID::CEI.new('A0BCDEFGH1')
|
|
@@ -370,11 +416,11 @@ cei.full_id # => 'A0BCDEFGH1'
|
|
|
370
416
|
cei.prefix # => 'A'
|
|
371
417
|
cei.numeric # => '0'
|
|
372
418
|
cei.entity_id # => 'BCDEFGH'
|
|
373
|
-
cei.
|
|
419
|
+
cei.checksum # => 1
|
|
374
420
|
cei.valid? # => true
|
|
375
421
|
cei.restore # => 'A0BCDEFGH1'
|
|
376
422
|
cei.restore! # => #<SecID::CEI> (mutates instance)
|
|
377
|
-
cei.
|
|
423
|
+
cei.calculate_checksum # => 1
|
|
378
424
|
```
|
|
379
425
|
|
|
380
426
|
### SEDOL
|
|
@@ -386,16 +432,16 @@ cei.calculate_check_digit # => 1
|
|
|
386
432
|
SecID::SEDOL.valid?('B0Z52W5') # => true
|
|
387
433
|
SecID::SEDOL.restore('B0Z52W') # => 'B0Z52W5'
|
|
388
434
|
SecID::SEDOL.restore!('B0Z52W') # => #<SecID::SEDOL>
|
|
389
|
-
SecID::SEDOL.
|
|
435
|
+
SecID::SEDOL.checksum('B0Z52W') # => 5
|
|
390
436
|
|
|
391
437
|
# instance level
|
|
392
438
|
sedol = SecID::SEDOL.new('B0Z52W5')
|
|
393
439
|
sedol.full_id # => 'B0Z52W5'
|
|
394
|
-
sedol.
|
|
440
|
+
sedol.checksum # => 5
|
|
395
441
|
sedol.valid? # => true
|
|
396
442
|
sedol.restore # => 'B0Z52W5'
|
|
397
443
|
sedol.restore! # => #<SecID::SEDOL> (mutates instance)
|
|
398
|
-
sedol.
|
|
444
|
+
sedol.calculate_checksum # => 5
|
|
399
445
|
sedol.to_isin # => #<SecID::ISIN> (GB ISIN by default)
|
|
400
446
|
sedol.to_isin('IE') # => #<SecID::ISIN> (IE ISIN)
|
|
401
447
|
```
|
|
@@ -409,18 +455,18 @@ sedol.to_isin('IE') # => #<SecID::ISIN> (IE ISIN)
|
|
|
409
455
|
SecID::FIGI.valid?('BBG000DMBXR2') # => true
|
|
410
456
|
SecID::FIGI.restore('BBG000DMBXR') # => 'BBG000DMBXR2'
|
|
411
457
|
SecID::FIGI.restore!('BBG000DMBXR') # => #<SecID::FIGI>
|
|
412
|
-
SecID::FIGI.
|
|
458
|
+
SecID::FIGI.checksum('BBG000DMBXR') # => 2
|
|
413
459
|
|
|
414
460
|
# instance level
|
|
415
461
|
figi = SecID::FIGI.new('BBG000DMBXR2')
|
|
416
462
|
figi.full_id # => 'BBG000DMBXR2'
|
|
417
463
|
figi.prefix # => 'BB'
|
|
418
464
|
figi.random_part # => '000DMBXR'
|
|
419
|
-
figi.
|
|
465
|
+
figi.checksum # => 2
|
|
420
466
|
figi.valid? # => true
|
|
421
467
|
figi.restore # => 'BBG000DMBXR2'
|
|
422
468
|
figi.restore! # => #<SecID::FIGI> (mutates instance)
|
|
423
|
-
figi.
|
|
469
|
+
figi.calculate_checksum # => 2
|
|
424
470
|
figi.to_pretty_s # => 'BBG 000DMBXR 2'
|
|
425
471
|
```
|
|
426
472
|
|
|
@@ -433,7 +479,7 @@ figi.to_pretty_s # => 'BBG 000DMBXR 2'
|
|
|
433
479
|
SecID::LEI.valid?('5493006MHB84DD0ZWV18') # => true
|
|
434
480
|
SecID::LEI.restore('5493006MHB84DD0ZWV') # => '5493006MHB84DD0ZWV18'
|
|
435
481
|
SecID::LEI.restore!('5493006MHB84DD0ZWV') # => #<SecID::LEI>
|
|
436
|
-
SecID::LEI.
|
|
482
|
+
SecID::LEI.checksum('5493006MHB84DD0ZWV') # => 18
|
|
437
483
|
|
|
438
484
|
# instance level
|
|
439
485
|
lei = SecID::LEI.new('5493006MHB84DD0ZWV18')
|
|
@@ -441,11 +487,11 @@ lei.full_id # => '5493006MHB84DD0ZWV18'
|
|
|
441
487
|
lei.lou_id # => '5493'
|
|
442
488
|
lei.reserved # => '00'
|
|
443
489
|
lei.entity_id # => '6MHB84DD0ZWV'
|
|
444
|
-
lei.
|
|
490
|
+
lei.checksum # => 18
|
|
445
491
|
lei.valid? # => true
|
|
446
492
|
lei.restore # => '5493006MHB84DD0ZWV18'
|
|
447
493
|
lei.restore! # => #<SecID::LEI> (mutates instance)
|
|
448
|
-
lei.
|
|
494
|
+
lei.calculate_checksum # => 18
|
|
449
495
|
lei.to_pretty_s # => '5493 006M HB84 DD0Z WV18'
|
|
450
496
|
```
|
|
451
497
|
|
|
@@ -458,7 +504,7 @@ lei.to_pretty_s # => '5493 006M HB84 DD0Z WV18'
|
|
|
458
504
|
SecID::IBAN.valid?('DE89370400440532013000') # => true
|
|
459
505
|
SecID::IBAN.restore('DE370400440532013000') # => 'DE89370400440532013000'
|
|
460
506
|
SecID::IBAN.restore!('DE370400440532013000') # => #<SecID::IBAN>
|
|
461
|
-
SecID::IBAN.
|
|
507
|
+
SecID::IBAN.checksum('DE370400440532013000') # => 89
|
|
462
508
|
|
|
463
509
|
# instance level
|
|
464
510
|
iban = SecID::IBAN.new('DE89370400440532013000')
|
|
@@ -467,11 +513,11 @@ iban.country_code # => 'DE'
|
|
|
467
513
|
iban.bban # => '370400440532013000'
|
|
468
514
|
iban.bank_code # => '37040044'
|
|
469
515
|
iban.account_number # => '0532013000'
|
|
470
|
-
iban.
|
|
516
|
+
iban.checksum # => 89
|
|
471
517
|
iban.valid? # => true
|
|
472
518
|
iban.restore # => 'DE89370400440532013000'
|
|
473
519
|
iban.restore! # => #<SecID::IBAN> (mutates instance)
|
|
474
|
-
iban.
|
|
520
|
+
iban.calculate_checksum # => 89
|
|
475
521
|
iban.known_country? # => true
|
|
476
522
|
iban.to_pretty_s # => 'DE89 3704 0044 0532 0130 00'
|
|
477
523
|
```
|
|
@@ -686,28 +732,54 @@ BIC validation confirms structure and a real country code only. It does **not**
|
|
|
686
732
|
SecID::DTI.valid?('X9J9K872S') # => true
|
|
687
733
|
SecID::DTI.restore('X9J9K872') # => 'X9J9K872S'
|
|
688
734
|
SecID::DTI.restore!('X9J9K872') # => #<SecID::DTI>
|
|
689
|
-
SecID::DTI.
|
|
735
|
+
SecID::DTI.checksum('X9J9K872') # => 'S'
|
|
690
736
|
|
|
691
737
|
# instance level
|
|
692
738
|
dti = SecID::DTI.new('X9J9K872S')
|
|
693
739
|
dti.full_id # => 'X9J9K872S'
|
|
694
740
|
dti.identifier # => 'X9J9K872'
|
|
695
|
-
dti.
|
|
741
|
+
dti.checksum # => 'S'
|
|
696
742
|
dti.valid? # => true
|
|
697
743
|
dti.restore # => 'X9J9K872S'
|
|
698
744
|
dti.restore! # => #<SecID::DTI> (mutates instance)
|
|
699
|
-
dti.
|
|
745
|
+
dti.calculate_checksum # => 'S'
|
|
700
746
|
```
|
|
701
747
|
|
|
702
|
-
DTI accepts exactly 9 characters: an 8-character base (first character never `0`) plus 1 check character, both drawn from a 30-symbol alphabet — digits `0`-`9` and consonants (vowels and `Y` never appear). Unlike
|
|
748
|
+
DTI accepts exactly 9 characters: an 8-character base (first character never `0`) plus 1 check character, both drawn from a 30-symbol alphabet — digits `0`-`9` and consonants (vowels and `Y` never appear). Unlike most checksum types in this gem, `checksum` and `calculate_checksum` return a `String`, not an `Integer` (as does UPI). The check character is computed fully offline via ISO 7064 hybrid MOD 31,30 — no registry lookup or paywalled ISO 24165-1 spec required.
|
|
703
749
|
|
|
704
|
-
> **Grandfathered code:** Bitcoin's registered code (`4H95J0R2X`) predates the algorithm and fails the MOD 31,30 computation (which yields `4H95J0R2T`). A frozen exception map honors the registry's assignment across `valid?`, `restore`, and `
|
|
750
|
+
> **Grandfathered code:** Bitcoin's registered code (`4H95J0R2X`) predates the algorithm and fails the MOD 31,30 computation (which yields `4H95J0R2T`). A frozen exception map honors the registry's assignment across `valid?`, `restore`, and `checksum` alike:
|
|
705
751
|
>
|
|
706
752
|
> ```ruby
|
|
707
753
|
> SecID::DTI.valid?('4H95J0R2X') # => true (registered code, via the exception map)
|
|
708
754
|
> SecID::DTI.valid?('4H95J0R2T') # => false (algorithmic form is not the registered one)
|
|
709
755
|
> ```
|
|
710
756
|
|
|
757
|
+
### UPI
|
|
758
|
+
|
|
759
|
+
> [Unique Product Identifier](https://www.anna-dsb.com) - a 12-character code identifying OTC derivative products per ISO 4914, issued by the ANNA Derivatives Service Bureau and reported under CFTC, EMIR, and other global OTC-derivatives mandates.
|
|
760
|
+
|
|
761
|
+
```ruby
|
|
762
|
+
# class level
|
|
763
|
+
SecID::UPI.valid?('QZRBG6ZTKS42') # => true
|
|
764
|
+
SecID::UPI.restore('QZRBG6ZTKS4') # => 'QZRBG6ZTKS42'
|
|
765
|
+
SecID::UPI.restore!('QZRBG6ZTKS4') # => #<SecID::UPI>
|
|
766
|
+
SecID::UPI.checksum('QZRBG6ZTKS4') # => '2'
|
|
767
|
+
|
|
768
|
+
# instance level
|
|
769
|
+
upi = SecID::UPI.new('QZRBG6ZTKS42')
|
|
770
|
+
upi.full_id # => 'QZRBG6ZTKS42'
|
|
771
|
+
upi.identifier # => 'QZRBG6ZTKS4'
|
|
772
|
+
upi.checksum # => '2'
|
|
773
|
+
upi.valid? # => true
|
|
774
|
+
upi.restore # => 'QZRBG6ZTKS42'
|
|
775
|
+
upi.restore! # => #<SecID::UPI> (mutates instance)
|
|
776
|
+
upi.calculate_checksum # => '2'
|
|
777
|
+
```
|
|
778
|
+
|
|
779
|
+
UPI accepts exactly 12 characters: a fixed `QZ` prefix, a 9-character body, and 1 check character, all drawn from the same 30-symbol alphabet as DTI — digits `0`-`9` and consonants (vowels and `Y` never appear). Like DTI, `checksum` and `calculate_checksum` return a `String`, not an `Integer`. The check character is computed fully offline via ISO 7064 hybrid MOD 31,30 over the 11 preceding characters — no DSB registry lookup or paywalled ISO 4914 spec required.
|
|
780
|
+
|
|
781
|
+
> **Coexistence with ISIN:** a UPI shares the 12-character length bucket with ISIN. A UPI whose digit check character also satisfies ISIN's Luhn detects as both (`SecID.detect('QZXKR05S3DL1') # => [:isin, :upi]`), with ISIN ranked first; `SecID.parse(..., on_ambiguous: :raise)` surfaces the collision. UPI validation itself is fully offline and existence is **not** verified — that requires the licensed DSB registry.
|
|
782
|
+
|
|
711
783
|
## ActiveModel / Rails Validator
|
|
712
784
|
|
|
713
785
|
SecID ships an opt-in [ActiveModel](https://api.rubyonrails.org/classes/ActiveModel/Validations.html) validator, registered as `sec_id`, for declarative validation of any supported identifier type. It adds **no runtime dependency** — `require 'sec_id'` loads none of it, and ActiveModel is a development/test dependency only.
|
|
@@ -758,7 +830,7 @@ On failure the validator adds one error under the `:sec_id` key with a type-awar
|
|
|
758
830
|
|
|
759
831
|
```ruby
|
|
760
832
|
validates :isin, sec_id: { type: :isin, details: true }
|
|
761
|
-
# a bad
|
|
833
|
+
# a bad checksum reports e.g. "Checksum '4' is invalid, expected '5'"
|
|
762
834
|
```
|
|
763
835
|
|
|
764
836
|
Standard `EachValidator` options — `allow_nil`, `allow_blank`, `if`, `unless`, `on` — work as usual. Tested against Rails 7.2, 8.0, and 8.1.
|
data/lib/sec_id/active_model.rb
CHANGED
|
@@ -93,7 +93,7 @@ class SecIdValidator < ActiveModel::EachValidator
|
|
|
93
93
|
#
|
|
94
94
|
# @return [Array<Symbol>]
|
|
95
95
|
def candidate_types
|
|
96
|
-
configured_types || SecID.identifiers.map
|
|
96
|
+
configured_types || SecID.identifiers.map(&:type_key)
|
|
97
97
|
end
|
|
98
98
|
|
|
99
99
|
# sec_id's specific failure reason, only when `details: true` and a single `type:` is set;
|