sec_id 6.1.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 +14 -0
- data/MIGRATION.md +147 -0
- data/README.md +71 -44
- data/lib/sec_id/base.rb +36 -17
- 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/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/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 +9 -3
- data/sec_id.gemspec +2 -2
- data/sig/sec_id/base.rbs +9 -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/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/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,20 @@ 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
|
+
|
|
11
25
|
## [6.1.0] - 2026-07-10
|
|
12
26
|
|
|
13
27
|
### 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
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
- [FISN](#fisn) - Financial Instrument Short Name
|
|
29
29
|
- [BIC](#bic) - Business Identifier Code / SWIFT code
|
|
30
30
|
- [DTI](#dti) - Digital Token Identifier
|
|
31
|
+
- [UPI](#upi) - Unique Product Identifier
|
|
31
32
|
- [ActiveModel / Rails Validator](#activemodel--rails-validator) - declarative `validates :isin, sec_id: {...}`
|
|
32
33
|
- [Lookup Service Integration](#lookup-service-integration)
|
|
33
34
|
- [Type Signatures (RBS)](#type-signatures-rbs)
|
|
@@ -46,7 +47,7 @@ Ruby 3.2+ is required.
|
|
|
46
47
|
Add this line to your application's Gemfile:
|
|
47
48
|
|
|
48
49
|
```ruby
|
|
49
|
-
gem 'sec_id', '~>
|
|
50
|
+
gem 'sec_id', '~> 7.0'
|
|
50
51
|
```
|
|
51
52
|
|
|
52
53
|
And then execute:
|
|
@@ -81,11 +82,11 @@ All identifier classes provide `valid?`, `errors`, `validate`, `validate!` metho
|
|
|
81
82
|
```ruby
|
|
82
83
|
SecID::ISIN.new('US5949181045').to_h
|
|
83
84
|
# => { type: :isin, full_id: 'US5949181045', normalized: 'US5949181045',
|
|
84
|
-
# valid: true, components: { country_code: 'US', nsin: '594918104',
|
|
85
|
+
# valid: true, components: { country_code: 'US', nsin: '594918104', checksum: 5 } }
|
|
85
86
|
|
|
86
87
|
SecID::ISIN.new('INVALID').to_h
|
|
87
88
|
# => { type: :isin, full_id: 'INVALID', normalized: nil,
|
|
88
|
-
# valid: false, components: { country_code: nil, nsin: nil,
|
|
89
|
+
# valid: false, components: { country_code: nil, nsin: nil, checksum: nil } }
|
|
89
90
|
```
|
|
90
91
|
|
|
91
92
|
**All identifiers** support value equality — two instances of the same type with the same normalized form are equal:
|
|
@@ -102,10 +103,10 @@ a.eql?(b) # => true
|
|
|
102
103
|
Set.new([a, b]).size # => 1
|
|
103
104
|
```
|
|
104
105
|
|
|
105
|
-
**
|
|
106
|
-
- `restore` / `.restore` - returns the full identifier string with correct
|
|
107
|
-
- `restore!` / `.restore!` - restores
|
|
108
|
-
- `
|
|
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
|
|
109
110
|
|
|
110
111
|
### Metadata Registry
|
|
111
112
|
|
|
@@ -126,14 +127,14 @@ SecID::ISIN.short_name # => "ISIN"
|
|
|
126
127
|
SecID::ISIN.full_name # => "International Securities Identification Number"
|
|
127
128
|
SecID::ISIN.id_length # => 12
|
|
128
129
|
SecID::ISIN.example # => "US5949181045"
|
|
129
|
-
SecID::ISIN.
|
|
130
|
+
SecID::ISIN.has_checksum? # => true
|
|
130
131
|
|
|
131
132
|
# Filter with standard Ruby
|
|
132
|
-
SecID.identifiers.select(&:
|
|
133
|
-
# => ["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"]
|
|
134
135
|
|
|
135
136
|
# Detect identifier type from an unknown string
|
|
136
|
-
# Results are sorted by specificity:
|
|
137
|
+
# Results are sorted by specificity: checksum types first, then by length precision
|
|
137
138
|
SecID.detect('US5949181045') # => [:isin]
|
|
138
139
|
SecID.detect('037833100') # => [:cusip, :valoren, :cik]
|
|
139
140
|
SecID.detect('APPLE INC/SH') # => [:fisn]
|
|
@@ -202,7 +203,7 @@ Understand why a string matches or doesn't match specific identifier types:
|
|
|
202
203
|
result = SecID.explain('US5949181040')
|
|
203
204
|
isin = result[:candidates].find { |c| c[:type] == :isin }
|
|
204
205
|
isin[:valid] # => false
|
|
205
|
-
isin[:errors].first[:error] # => :
|
|
206
|
+
isin[:errors].first[:error] # => :invalid_checksum
|
|
206
207
|
|
|
207
208
|
# Filter to specific types
|
|
208
209
|
SecID.explain('US5949181045', types: %i[isin cusip])
|
|
@@ -215,8 +216,8 @@ All identifier classes provide a Rails-like `#errors` API for detailed error rep
|
|
|
215
216
|
```ruby
|
|
216
217
|
isin = SecID::ISIN.new('US5949181040')
|
|
217
218
|
isin.errors.none? # => false
|
|
218
|
-
isin.errors.messages # => ["
|
|
219
|
-
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'" }]
|
|
220
221
|
isin.errors.any? # => true
|
|
221
222
|
isin.errors.empty? # => false
|
|
222
223
|
isin.errors.size # => 1
|
|
@@ -233,7 +234,7 @@ SecID::ISIN.validate('US5949181040').errors # => #<SecID::Errors>
|
|
|
233
234
|
- `:invalid_format` - correct length and characters but wrong structure
|
|
234
235
|
|
|
235
236
|
**Type-specific error codes:**
|
|
236
|
-
- `:
|
|
237
|
+
- `:invalid_checksum` - checksum mismatch (ISIN, CUSIP, SEDOL, FIGI, LEI, IBAN, CEI, DTI, UPI)
|
|
237
238
|
- `:invalid_prefix` - restricted FIGI prefix (FIGI)
|
|
238
239
|
- `:invalid_category` - unknown CFI category code (CFI)
|
|
239
240
|
- `:invalid_group` - unknown CFI group code for category (CFI)
|
|
@@ -255,7 +256,7 @@ SecID::ISIN.new('INVALID').validate!
|
|
|
255
256
|
# => SecID::InvalidFormatError: Expected 12 characters, got 7
|
|
256
257
|
|
|
257
258
|
SecID::ISIN.new('US5949181040').validate!
|
|
258
|
-
# => SecID::
|
|
259
|
+
# => SecID::InvalidChecksumError: Checksum '0' is invalid, expected '5'
|
|
259
260
|
|
|
260
261
|
SecID::FIGI.new('BSG000BLNNH6').validate!
|
|
261
262
|
# => SecID::InvalidStructureError: Prefix 'BS' is restricted
|
|
@@ -308,7 +309,7 @@ at all — `#to_h`'s envelope keys (`:type`, `:full_id`, `:normalized`, `:valid`
|
|
|
308
309
|
|
|
309
310
|
### Generating Test Fixtures
|
|
310
311
|
|
|
311
|
-
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:
|
|
312
313
|
|
|
313
314
|
```ruby
|
|
314
315
|
SecID::ISIN.generate # => #<SecID::ISIN ...>
|
|
@@ -336,18 +337,18 @@ SecID::LEI.generate(random: Random.new(42)) == SecID::LEI.generate(random: Rando
|
|
|
336
337
|
SecID::ISIN.valid?('US5949181045') # => true
|
|
337
338
|
SecID::ISIN.restore('US594918104') # => 'US5949181045'
|
|
338
339
|
SecID::ISIN.restore!('US594918104') # => #<SecID::ISIN>
|
|
339
|
-
SecID::ISIN.
|
|
340
|
+
SecID::ISIN.checksum('US594918104') # => 5
|
|
340
341
|
|
|
341
342
|
# instance level
|
|
342
343
|
isin = SecID::ISIN.new('US5949181045')
|
|
343
344
|
isin.full_id # => 'US5949181045'
|
|
344
345
|
isin.country_code # => 'US'
|
|
345
346
|
isin.nsin # => '594918104'
|
|
346
|
-
isin.
|
|
347
|
+
isin.checksum # => 5
|
|
347
348
|
isin.valid? # => true
|
|
348
349
|
isin.restore # => 'US5949181045'
|
|
349
350
|
isin.restore! # => #<SecID::ISIN> (mutates instance)
|
|
350
|
-
isin.
|
|
351
|
+
isin.calculate_checksum # => 5
|
|
351
352
|
isin.to_pretty_s # => 'US 594918104 5'
|
|
352
353
|
isin.to_cusip # => #<SecID::CUSIP>
|
|
353
354
|
isin.nsin_type # => :cusip
|
|
@@ -381,18 +382,18 @@ SecID::ISIN.new('CH0012221716').to_valoren # => #<SecID::Valoren>
|
|
|
381
382
|
SecID::CUSIP.valid?('594918104') # => true
|
|
382
383
|
SecID::CUSIP.restore('59491810') # => '594918104'
|
|
383
384
|
SecID::CUSIP.restore!('59491810') # => #<SecID::CUSIP>
|
|
384
|
-
SecID::CUSIP.
|
|
385
|
+
SecID::CUSIP.checksum('59491810') # => 4
|
|
385
386
|
|
|
386
387
|
# instance level
|
|
387
388
|
cusip = SecID::CUSIP.new('594918104')
|
|
388
389
|
cusip.full_id # => '594918104'
|
|
389
390
|
cusip.cusip6 # => '594918'
|
|
390
391
|
cusip.issue # => '10'
|
|
391
|
-
cusip.
|
|
392
|
+
cusip.checksum # => 4
|
|
392
393
|
cusip.valid? # => true
|
|
393
394
|
cusip.restore # => '594918104'
|
|
394
395
|
cusip.restore! # => #<SecID::CUSIP> (mutates instance)
|
|
395
|
-
cusip.
|
|
396
|
+
cusip.calculate_checksum # => 4
|
|
396
397
|
cusip.to_pretty_s # => '594918 10 4'
|
|
397
398
|
cusip.to_isin('US') # => #<SecID::ISIN>
|
|
398
399
|
cusip.cins? # => false
|
|
@@ -407,7 +408,7 @@ cusip.cins? # => false
|
|
|
407
408
|
SecID::CEI.valid?('A0BCDEFGH1') # => true
|
|
408
409
|
SecID::CEI.restore('A0BCDEFGH') # => 'A0BCDEFGH1'
|
|
409
410
|
SecID::CEI.restore!('A0BCDEFGH') # => #<SecID::CEI>
|
|
410
|
-
SecID::CEI.
|
|
411
|
+
SecID::CEI.checksum('A0BCDEFGH') # => 1
|
|
411
412
|
|
|
412
413
|
# instance level
|
|
413
414
|
cei = SecID::CEI.new('A0BCDEFGH1')
|
|
@@ -415,11 +416,11 @@ cei.full_id # => 'A0BCDEFGH1'
|
|
|
415
416
|
cei.prefix # => 'A'
|
|
416
417
|
cei.numeric # => '0'
|
|
417
418
|
cei.entity_id # => 'BCDEFGH'
|
|
418
|
-
cei.
|
|
419
|
+
cei.checksum # => 1
|
|
419
420
|
cei.valid? # => true
|
|
420
421
|
cei.restore # => 'A0BCDEFGH1'
|
|
421
422
|
cei.restore! # => #<SecID::CEI> (mutates instance)
|
|
422
|
-
cei.
|
|
423
|
+
cei.calculate_checksum # => 1
|
|
423
424
|
```
|
|
424
425
|
|
|
425
426
|
### SEDOL
|
|
@@ -431,16 +432,16 @@ cei.calculate_check_digit # => 1
|
|
|
431
432
|
SecID::SEDOL.valid?('B0Z52W5') # => true
|
|
432
433
|
SecID::SEDOL.restore('B0Z52W') # => 'B0Z52W5'
|
|
433
434
|
SecID::SEDOL.restore!('B0Z52W') # => #<SecID::SEDOL>
|
|
434
|
-
SecID::SEDOL.
|
|
435
|
+
SecID::SEDOL.checksum('B0Z52W') # => 5
|
|
435
436
|
|
|
436
437
|
# instance level
|
|
437
438
|
sedol = SecID::SEDOL.new('B0Z52W5')
|
|
438
439
|
sedol.full_id # => 'B0Z52W5'
|
|
439
|
-
sedol.
|
|
440
|
+
sedol.checksum # => 5
|
|
440
441
|
sedol.valid? # => true
|
|
441
442
|
sedol.restore # => 'B0Z52W5'
|
|
442
443
|
sedol.restore! # => #<SecID::SEDOL> (mutates instance)
|
|
443
|
-
sedol.
|
|
444
|
+
sedol.calculate_checksum # => 5
|
|
444
445
|
sedol.to_isin # => #<SecID::ISIN> (GB ISIN by default)
|
|
445
446
|
sedol.to_isin('IE') # => #<SecID::ISIN> (IE ISIN)
|
|
446
447
|
```
|
|
@@ -454,18 +455,18 @@ sedol.to_isin('IE') # => #<SecID::ISIN> (IE ISIN)
|
|
|
454
455
|
SecID::FIGI.valid?('BBG000DMBXR2') # => true
|
|
455
456
|
SecID::FIGI.restore('BBG000DMBXR') # => 'BBG000DMBXR2'
|
|
456
457
|
SecID::FIGI.restore!('BBG000DMBXR') # => #<SecID::FIGI>
|
|
457
|
-
SecID::FIGI.
|
|
458
|
+
SecID::FIGI.checksum('BBG000DMBXR') # => 2
|
|
458
459
|
|
|
459
460
|
# instance level
|
|
460
461
|
figi = SecID::FIGI.new('BBG000DMBXR2')
|
|
461
462
|
figi.full_id # => 'BBG000DMBXR2'
|
|
462
463
|
figi.prefix # => 'BB'
|
|
463
464
|
figi.random_part # => '000DMBXR'
|
|
464
|
-
figi.
|
|
465
|
+
figi.checksum # => 2
|
|
465
466
|
figi.valid? # => true
|
|
466
467
|
figi.restore # => 'BBG000DMBXR2'
|
|
467
468
|
figi.restore! # => #<SecID::FIGI> (mutates instance)
|
|
468
|
-
figi.
|
|
469
|
+
figi.calculate_checksum # => 2
|
|
469
470
|
figi.to_pretty_s # => 'BBG 000DMBXR 2'
|
|
470
471
|
```
|
|
471
472
|
|
|
@@ -478,7 +479,7 @@ figi.to_pretty_s # => 'BBG 000DMBXR 2'
|
|
|
478
479
|
SecID::LEI.valid?('5493006MHB84DD0ZWV18') # => true
|
|
479
480
|
SecID::LEI.restore('5493006MHB84DD0ZWV') # => '5493006MHB84DD0ZWV18'
|
|
480
481
|
SecID::LEI.restore!('5493006MHB84DD0ZWV') # => #<SecID::LEI>
|
|
481
|
-
SecID::LEI.
|
|
482
|
+
SecID::LEI.checksum('5493006MHB84DD0ZWV') # => 18
|
|
482
483
|
|
|
483
484
|
# instance level
|
|
484
485
|
lei = SecID::LEI.new('5493006MHB84DD0ZWV18')
|
|
@@ -486,11 +487,11 @@ lei.full_id # => '5493006MHB84DD0ZWV18'
|
|
|
486
487
|
lei.lou_id # => '5493'
|
|
487
488
|
lei.reserved # => '00'
|
|
488
489
|
lei.entity_id # => '6MHB84DD0ZWV'
|
|
489
|
-
lei.
|
|
490
|
+
lei.checksum # => 18
|
|
490
491
|
lei.valid? # => true
|
|
491
492
|
lei.restore # => '5493006MHB84DD0ZWV18'
|
|
492
493
|
lei.restore! # => #<SecID::LEI> (mutates instance)
|
|
493
|
-
lei.
|
|
494
|
+
lei.calculate_checksum # => 18
|
|
494
495
|
lei.to_pretty_s # => '5493 006M HB84 DD0Z WV18'
|
|
495
496
|
```
|
|
496
497
|
|
|
@@ -503,7 +504,7 @@ lei.to_pretty_s # => '5493 006M HB84 DD0Z WV18'
|
|
|
503
504
|
SecID::IBAN.valid?('DE89370400440532013000') # => true
|
|
504
505
|
SecID::IBAN.restore('DE370400440532013000') # => 'DE89370400440532013000'
|
|
505
506
|
SecID::IBAN.restore!('DE370400440532013000') # => #<SecID::IBAN>
|
|
506
|
-
SecID::IBAN.
|
|
507
|
+
SecID::IBAN.checksum('DE370400440532013000') # => 89
|
|
507
508
|
|
|
508
509
|
# instance level
|
|
509
510
|
iban = SecID::IBAN.new('DE89370400440532013000')
|
|
@@ -512,11 +513,11 @@ iban.country_code # => 'DE'
|
|
|
512
513
|
iban.bban # => '370400440532013000'
|
|
513
514
|
iban.bank_code # => '37040044'
|
|
514
515
|
iban.account_number # => '0532013000'
|
|
515
|
-
iban.
|
|
516
|
+
iban.checksum # => 89
|
|
516
517
|
iban.valid? # => true
|
|
517
518
|
iban.restore # => 'DE89370400440532013000'
|
|
518
519
|
iban.restore! # => #<SecID::IBAN> (mutates instance)
|
|
519
|
-
iban.
|
|
520
|
+
iban.calculate_checksum # => 89
|
|
520
521
|
iban.known_country? # => true
|
|
521
522
|
iban.to_pretty_s # => 'DE89 3704 0044 0532 0130 00'
|
|
522
523
|
```
|
|
@@ -731,28 +732,54 @@ BIC validation confirms structure and a real country code only. It does **not**
|
|
|
731
732
|
SecID::DTI.valid?('X9J9K872S') # => true
|
|
732
733
|
SecID::DTI.restore('X9J9K872') # => 'X9J9K872S'
|
|
733
734
|
SecID::DTI.restore!('X9J9K872') # => #<SecID::DTI>
|
|
734
|
-
SecID::DTI.
|
|
735
|
+
SecID::DTI.checksum('X9J9K872') # => 'S'
|
|
735
736
|
|
|
736
737
|
# instance level
|
|
737
738
|
dti = SecID::DTI.new('X9J9K872S')
|
|
738
739
|
dti.full_id # => 'X9J9K872S'
|
|
739
740
|
dti.identifier # => 'X9J9K872'
|
|
740
|
-
dti.
|
|
741
|
+
dti.checksum # => 'S'
|
|
741
742
|
dti.valid? # => true
|
|
742
743
|
dti.restore # => 'X9J9K872S'
|
|
743
744
|
dti.restore! # => #<SecID::DTI> (mutates instance)
|
|
744
|
-
dti.
|
|
745
|
+
dti.calculate_checksum # => 'S'
|
|
745
746
|
```
|
|
746
747
|
|
|
747
|
-
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.
|
|
748
749
|
|
|
749
|
-
> **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:
|
|
750
751
|
>
|
|
751
752
|
> ```ruby
|
|
752
753
|
> SecID::DTI.valid?('4H95J0R2X') # => true (registered code, via the exception map)
|
|
753
754
|
> SecID::DTI.valid?('4H95J0R2T') # => false (algorithmic form is not the registered one)
|
|
754
755
|
> ```
|
|
755
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
|
+
|
|
756
783
|
## ActiveModel / Rails Validator
|
|
757
784
|
|
|
758
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.
|
|
@@ -803,7 +830,7 @@ On failure the validator adds one error under the `:sec_id` key with a type-awar
|
|
|
803
830
|
|
|
804
831
|
```ruby
|
|
805
832
|
validates :isin, sec_id: { type: :isin, details: true }
|
|
806
|
-
# a bad
|
|
833
|
+
# a bad checksum reports e.g. "Checksum '4' is invalid, expected '5'"
|
|
807
834
|
```
|
|
808
835
|
|
|
809
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/base.rb
CHANGED
|
@@ -8,28 +8,28 @@ module SecID
|
|
|
8
8
|
# - ID_REGEX constant with named capture groups for parsing
|
|
9
9
|
# - initialize method that calls parse and extracts components
|
|
10
10
|
#
|
|
11
|
-
# Subclasses with
|
|
12
|
-
# which provides
|
|
11
|
+
# Subclasses with checksum should also include the Checkable concern,
|
|
12
|
+
# which provides checksum validation, calculation, and restoration.
|
|
13
13
|
#
|
|
14
|
-
# @example Implementing a
|
|
14
|
+
# @example Implementing a checksum identifier
|
|
15
15
|
# class MyIdentifier < Base
|
|
16
16
|
# include Checkable
|
|
17
17
|
#
|
|
18
|
-
# ID_REGEX = /\A(?<identifier>[A-Z]{6})(?<
|
|
18
|
+
# ID_REGEX = /\A(?<identifier>[A-Z]{6})(?<checksum>\d)?\z/x
|
|
19
19
|
#
|
|
20
20
|
# def initialize(id)
|
|
21
21
|
# parts = parse(id)
|
|
22
22
|
# @identifier = parts[:identifier]
|
|
23
|
-
# @
|
|
23
|
+
# @checksum = parts[:checksum]&.to_i
|
|
24
24
|
# end
|
|
25
25
|
#
|
|
26
|
-
# def
|
|
26
|
+
# def calculate_checksum
|
|
27
27
|
# validate_format_for_calculation!
|
|
28
28
|
# mod10(some_algorithm)
|
|
29
29
|
# end
|
|
30
30
|
# end
|
|
31
31
|
#
|
|
32
|
-
# @example Implementing a non-
|
|
32
|
+
# @example Implementing a non-checksum identifier
|
|
33
33
|
# class SimpleId < Base
|
|
34
34
|
# ID_REGEX = /\A(?<identifier>[A-Z]{6})\z/x
|
|
35
35
|
#
|
|
@@ -46,7 +46,7 @@ module SecID
|
|
|
46
46
|
# @return [String] the original input after normalization (stripped and uppercased)
|
|
47
47
|
attr_reader :full_id
|
|
48
48
|
|
|
49
|
-
# @return [String, nil] the main identifier portion (without
|
|
49
|
+
# @return [String, nil] the main identifier portion (without checksum)
|
|
50
50
|
attr_reader :identifier
|
|
51
51
|
|
|
52
52
|
class << self
|
|
@@ -55,15 +55,15 @@ module SecID
|
|
|
55
55
|
# @return [Symbol] the registry key (e.g. :isin, :cusip)
|
|
56
56
|
def type_key = @type_key ||= short_name.downcase.to_sym
|
|
57
57
|
|
|
58
|
-
# Composite sort key ranking detection specificity:
|
|
58
|
+
# Composite sort key ranking detection specificity: checksum types first,
|
|
59
59
|
# then narrower length range, then registration order. A class the registry
|
|
60
60
|
# never saw sorts last.
|
|
61
61
|
#
|
|
62
62
|
# @api private
|
|
63
|
-
# @return [Array] frozen [
|
|
63
|
+
# @return [Array] frozen [checksum rank, length specificity, registration order]
|
|
64
64
|
def detection_priority
|
|
65
65
|
@detection_priority ||=
|
|
66
|
-
[
|
|
66
|
+
[has_checksum? ? 0 : 1, length_specificity, SecID.identifiers.index(self) || Float::INFINITY].freeze
|
|
67
67
|
end
|
|
68
68
|
|
|
69
69
|
# @return [String] the unqualified class name (e.g. "ISIN", "CUSIP")
|
|
@@ -89,11 +89,19 @@ module SecID
|
|
|
89
89
|
# @return [String] a representative valid identifier string
|
|
90
90
|
def example = self::EXAMPLE
|
|
91
91
|
|
|
92
|
-
# @return [Boolean] true if this identifier type uses a
|
|
93
|
-
def
|
|
94
|
-
return @
|
|
92
|
+
# @return [Boolean] true if this identifier type uses a checksum
|
|
93
|
+
def has_checksum?
|
|
94
|
+
return @has_checksum if defined?(@has_checksum)
|
|
95
|
+
|
|
96
|
+
@has_checksum = ancestors.include?(SecID::Checkable)
|
|
97
|
+
end
|
|
95
98
|
|
|
96
|
-
|
|
99
|
+
# @deprecated Use {.has_checksum?}. Kept as a v7 bridge; removed in v8.
|
|
100
|
+
#
|
|
101
|
+
# @return [Boolean]
|
|
102
|
+
def has_check_digit?
|
|
103
|
+
SecID::Deprecation.warn(old: 'has_check_digit?', new: 'has_checksum?')
|
|
104
|
+
has_checksum?
|
|
97
105
|
end
|
|
98
106
|
end
|
|
99
107
|
|
|
@@ -134,7 +142,7 @@ module SecID
|
|
|
134
142
|
full_id: full_id,
|
|
135
143
|
normalized: valid? ? normalized : nil,
|
|
136
144
|
valid: valid?,
|
|
137
|
-
components:
|
|
145
|
+
components: components_with_deprecation_bridge
|
|
138
146
|
}
|
|
139
147
|
end
|
|
140
148
|
|
|
@@ -157,7 +165,7 @@ module SecID
|
|
|
157
165
|
# in SecID::ISIN[country_code:, nsin:] then [country_code, nsin]
|
|
158
166
|
# in nil then :invalid
|
|
159
167
|
# end #=> ['US', '594918104']
|
|
160
|
-
def deconstruct_keys(_keys) =
|
|
168
|
+
def deconstruct_keys(_keys) = components_with_deprecation_bridge
|
|
161
169
|
|
|
162
170
|
protected
|
|
163
171
|
|
|
@@ -168,6 +176,17 @@ module SecID
|
|
|
168
176
|
|
|
169
177
|
private
|
|
170
178
|
|
|
179
|
+
# Mirrors the canonical `:checksum` components key onto the deprecated `:check_digit`
|
|
180
|
+
# key for the v7 bridge, when a checksum is present. The mirror reads `:checksum`, so the
|
|
181
|
+
# deprecated `check_digit` reader never fires internally. Removed in v8 (revert `to_h` and
|
|
182
|
+
# `deconstruct_keys` to call `components` directly).
|
|
183
|
+
#
|
|
184
|
+
# @return [Hash] the parsed components, with `:check_digit` added when `:checksum` is present
|
|
185
|
+
def components_with_deprecation_bridge
|
|
186
|
+
parsed = components
|
|
187
|
+
parsed.key?(:checksum) ? parsed.merge(check_digit: parsed[:checksum]) : parsed
|
|
188
|
+
end
|
|
189
|
+
|
|
171
190
|
# @return [Hash]
|
|
172
191
|
def components
|
|
173
192
|
{}
|