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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/MIGRATION.md +147 -0
  4. data/README.md +116 -44
  5. data/lib/sec_id/active_model.rb +1 -1
  6. data/lib/sec_id/base.rb +63 -14
  7. data/lib/sec_id/cei.rb +7 -7
  8. data/lib/sec_id/cik.rb +1 -1
  9. data/lib/sec_id/concerns/checkable.rb +90 -46
  10. data/lib/sec_id/concerns/generatable.rb +5 -5
  11. data/lib/sec_id/concerns/normalizable.rb +4 -4
  12. data/lib/sec_id/concerns/validatable.rb +7 -7
  13. data/lib/sec_id/cusip.rb +8 -8
  14. data/lib/sec_id/deprecation.rb +26 -0
  15. data/lib/sec_id/detector.rb +3 -26
  16. data/lib/sec_id/dti.rb +8 -14
  17. data/lib/sec_id/figi.rb +9 -9
  18. data/lib/sec_id/iban.rb +20 -20
  19. data/lib/sec_id/isin.rb +9 -9
  20. data/lib/sec_id/lei.rb +10 -10
  21. data/lib/sec_id/occ.rb +1 -1
  22. data/lib/sec_id/scanner.rb +17 -22
  23. data/lib/sec_id/sedol.rb +11 -11
  24. data/lib/sec_id/upi.rb +76 -0
  25. data/lib/sec_id/valoren.rb +2 -2
  26. data/lib/sec_id/version.rb +1 -1
  27. data/lib/sec_id/wkn.rb +1 -1
  28. data/lib/sec_id.rb +11 -6
  29. data/sec_id.gemspec +2 -2
  30. data/sig/sec_id/base.rbs +15 -4
  31. data/sig/sec_id/cei.rbs +4 -0
  32. data/sig/sec_id/concerns/checkable.rbs +17 -7
  33. data/sig/sec_id/concerns/generatable.rbs +1 -1
  34. data/sig/sec_id/concerns/validatable.rbs +1 -1
  35. data/sig/sec_id/cusip.rbs +4 -0
  36. data/sig/sec_id/deprecation.rbs +6 -0
  37. data/sig/sec_id/detector.rbs +0 -6
  38. data/sig/sec_id/dti.rbs +6 -1
  39. data/sig/sec_id/figi.rbs +4 -0
  40. data/sig/sec_id/iban.rbs +7 -3
  41. data/sig/sec_id/isin.rbs +4 -0
  42. data/sig/sec_id/lei.rbs +5 -1
  43. data/sig/sec_id/scanner.rbs +2 -4
  44. data/sig/sec_id/sedol.rbs +4 -0
  45. data/sig/sec_id/upi.rbs +29 -0
  46. data/sig/sec_id.rbs +5 -2
  47. metadata +7 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c629207aa12be217cf0de6bcb9f7429731da6f33e28b4829c21e789c01246d70
4
- data.tar.gz: 18c299442df23cba47816bf3f6ebc7d274299f4e87cb82430018fd3e1ce11f8b
3
+ metadata.gz: b89119bace7665d5cbf511fc3ddf23083e375f7b8eff25dea56b00b584868e20
4
+ data.tar.gz: a9bb04a1ae20c48c150b2775f55d8f262707b7c6a8510283d6896b8b78dc1876
5
5
  SHA512:
6
- metadata.gz: 7210e01bab67effdb53376fcd6d4de7ecdf17c61ef505db439a8a08c5ab5e29960b48e0ef57000bbc68c9bd0feb8f123ad1fb397df25104414205a5720f21ef9
7
- data.tar.gz: 8325b5b97cd4e1edc9d4e2eae1537e1aa7f02d48d73ad8cda502df4075efe33ffa86ba1ed625c4e45b7cdd748606ac5ed32e509c54be128f310fc8d7b4ca978f
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', '~> 6.0'
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', check_digit: 5 } }
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, check_digit: 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
- **Check-digit based identifiers** (ISIN, CUSIP, CEI, SEDOL, FIGI, LEI, IBAN, DTI) also provide:
104
- - `restore` / `.restore` - returns the full identifier string with correct check-digit (no mutation)
105
- - `restore!` / `.restore!` - restores check-digit in place and returns `self` / instance
106
- - `check_digit` / `calculate_check_digit` - calculates and returns the check-digit
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.has_check_digit? # => true
130
+ SecID::ISIN.has_checksum? # => true
127
131
 
128
132
  # Filter with standard Ruby
129
- SecID.identifiers.select(&:has_check_digit?).map(&:short_name)
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: check-digit types first, then by length precision
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] # => :invalid_check_digit
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 # => ["Check digit '0' is invalid, expected '5'"]
216
- isin.errors.details # => [{ error: :invalid_check_digit, message: "Check digit '0' is invalid, expected '5'" }]
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
- - `:invalid_check_digit` - check digit mismatch (ISIN, CUSIP, SEDOL, FIGI, LEI, IBAN, CEI, DTI)
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::InvalidCheckDigitError: Check digit '0' is invalid, expected '5'
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 check digits where applicable — for use as test fixtures. Available per class and via the central dispatcher:
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.check_digit('US594918104') # => 5
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.check_digit # => 5
347
+ isin.checksum # => 5
302
348
  isin.valid? # => true
303
349
  isin.restore # => 'US5949181045'
304
350
  isin.restore! # => #<SecID::ISIN> (mutates instance)
305
- isin.calculate_check_digit # => 5
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.check_digit('59491810') # => 4
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.check_digit # => 4
392
+ cusip.checksum # => 4
347
393
  cusip.valid? # => true
348
394
  cusip.restore # => '594918104'
349
395
  cusip.restore! # => #<SecID::CUSIP> (mutates instance)
350
- cusip.calculate_check_digit # => 4
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.check_digit('A0BCDEFGH') # => 1
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.check_digit # => 1
419
+ cei.checksum # => 1
374
420
  cei.valid? # => true
375
421
  cei.restore # => 'A0BCDEFGH1'
376
422
  cei.restore! # => #<SecID::CEI> (mutates instance)
377
- cei.calculate_check_digit # => 1
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.check_digit('B0Z52W') # => 5
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.check_digit # => 5
440
+ sedol.checksum # => 5
395
441
  sedol.valid? # => true
396
442
  sedol.restore # => 'B0Z52W5'
397
443
  sedol.restore! # => #<SecID::SEDOL> (mutates instance)
398
- sedol.calculate_check_digit # => 5
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.check_digit('BBG000DMBXR') # => 2
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.check_digit # => 2
465
+ figi.checksum # => 2
420
466
  figi.valid? # => true
421
467
  figi.restore # => 'BBG000DMBXR2'
422
468
  figi.restore! # => #<SecID::FIGI> (mutates instance)
423
- figi.calculate_check_digit # => 2
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.check_digit('5493006MHB84DD0ZWV') # => 18
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.check_digit # => 18
490
+ lei.checksum # => 18
445
491
  lei.valid? # => true
446
492
  lei.restore # => '5493006MHB84DD0ZWV18'
447
493
  lei.restore! # => #<SecID::LEI> (mutates instance)
448
- lei.calculate_check_digit # => 18
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.check_digit('DE370400440532013000') # => 89
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.check_digit # => 89
516
+ iban.checksum # => 89
471
517
  iban.valid? # => true
472
518
  iban.restore # => 'DE89370400440532013000'
473
519
  iban.restore! # => #<SecID::IBAN> (mutates instance)
474
- iban.calculate_check_digit # => 89
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.check_digit('X9J9K872') # => 'S'
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.check_digit # => 'S'
741
+ dti.checksum # => 'S'
696
742
  dti.valid? # => true
697
743
  dti.restore # => 'X9J9K872S'
698
744
  dti.restore! # => #<SecID::DTI> (mutates instance)
699
- dti.calculate_check_digit # => 'S'
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 every other check-digit type in this gem, `check_digit` and `calculate_check_digit` return a `String`, not an `Integer`. 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
+ 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 `check_digit` alike:
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 check digit reports e.g. "Check digit '4' is invalid, expected '5'"
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.
@@ -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 { |klass| klass.short_name.downcase.to_sym }
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;