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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2c705053b66b513f6cbac21702cd4548f5f446669f9251db569d62693698ff64
4
- data.tar.gz: b792e03a67c57ead4064c392925771033c35b68fd984ad93aa5d932e37d1ea21
3
+ metadata.gz: b89119bace7665d5cbf511fc3ddf23083e375f7b8eff25dea56b00b584868e20
4
+ data.tar.gz: a9bb04a1ae20c48c150b2775f55d8f262707b7c6a8510283d6896b8b78dc1876
5
5
  SHA512:
6
- metadata.gz: 51880f7c27744b9700e01bca80a6f3a9a436d76a843921fdc97a401e12ef0acb2011c4f67a19034c30ea90e59638de0b66cb8a949b32d2d896e28a4f7957c4ba
7
- data.tar.gz: 780d1cd07415046d2c5dfda3b6f548785ae0e244bdb294876440521f90475562000beaf493c88109d609e9c65fa83b94ad67c8509084174a84e52e6be9268543
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', '~> 6.0'
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', check_digit: 5 } }
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, check_digit: 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
- **Check-digit based identifiers** (ISIN, CUSIP, CEI, SEDOL, FIGI, LEI, IBAN, DTI) also provide:
106
- - `restore` / `.restore` - returns the full identifier string with correct check-digit (no mutation)
107
- - `restore!` / `.restore!` - restores check-digit in place and returns `self` / instance
108
- - `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
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.has_check_digit? # => true
130
+ SecID::ISIN.has_checksum? # => true
130
131
 
131
132
  # Filter with standard Ruby
132
- SecID.identifiers.select(&:has_check_digit?).map(&:short_name)
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: check-digit types first, then by length precision
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] # => :invalid_check_digit
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 # => ["Check digit '0' is invalid, expected '5'"]
219
- 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'" }]
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
- - `: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)
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::InvalidCheckDigitError: Check digit '0' is invalid, expected '5'
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 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:
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.check_digit('US594918104') # => 5
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.check_digit # => 5
347
+ isin.checksum # => 5
347
348
  isin.valid? # => true
348
349
  isin.restore # => 'US5949181045'
349
350
  isin.restore! # => #<SecID::ISIN> (mutates instance)
350
- isin.calculate_check_digit # => 5
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.check_digit('59491810') # => 4
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.check_digit # => 4
392
+ cusip.checksum # => 4
392
393
  cusip.valid? # => true
393
394
  cusip.restore # => '594918104'
394
395
  cusip.restore! # => #<SecID::CUSIP> (mutates instance)
395
- cusip.calculate_check_digit # => 4
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.check_digit('A0BCDEFGH') # => 1
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.check_digit # => 1
419
+ cei.checksum # => 1
419
420
  cei.valid? # => true
420
421
  cei.restore # => 'A0BCDEFGH1'
421
422
  cei.restore! # => #<SecID::CEI> (mutates instance)
422
- cei.calculate_check_digit # => 1
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.check_digit('B0Z52W') # => 5
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.check_digit # => 5
440
+ sedol.checksum # => 5
440
441
  sedol.valid? # => true
441
442
  sedol.restore # => 'B0Z52W5'
442
443
  sedol.restore! # => #<SecID::SEDOL> (mutates instance)
443
- sedol.calculate_check_digit # => 5
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.check_digit('BBG000DMBXR') # => 2
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.check_digit # => 2
465
+ figi.checksum # => 2
465
466
  figi.valid? # => true
466
467
  figi.restore # => 'BBG000DMBXR2'
467
468
  figi.restore! # => #<SecID::FIGI> (mutates instance)
468
- figi.calculate_check_digit # => 2
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.check_digit('5493006MHB84DD0ZWV') # => 18
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.check_digit # => 18
490
+ lei.checksum # => 18
490
491
  lei.valid? # => true
491
492
  lei.restore # => '5493006MHB84DD0ZWV18'
492
493
  lei.restore! # => #<SecID::LEI> (mutates instance)
493
- lei.calculate_check_digit # => 18
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.check_digit('DE370400440532013000') # => 89
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.check_digit # => 89
516
+ iban.checksum # => 89
516
517
  iban.valid? # => true
517
518
  iban.restore # => 'DE89370400440532013000'
518
519
  iban.restore! # => #<SecID::IBAN> (mutates instance)
519
- iban.calculate_check_digit # => 89
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.check_digit('X9J9K872') # => 'S'
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.check_digit # => 'S'
741
+ dti.checksum # => 'S'
741
742
  dti.valid? # => true
742
743
  dti.restore # => 'X9J9K872S'
743
744
  dti.restore! # => #<SecID::DTI> (mutates instance)
744
- dti.calculate_check_digit # => 'S'
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 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.
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 `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:
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 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'"
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 check digits should also include the Checkable concern,
12
- # which provides check-digit validation, calculation, and restoration.
11
+ # Subclasses with checksum should also include the Checkable concern,
12
+ # which provides checksum validation, calculation, and restoration.
13
13
  #
14
- # @example Implementing a check-digit identifier
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})(?<check_digit>\d)?\z/x
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
- # @check_digit = parts[:check_digit]&.to_i
23
+ # @checksum = parts[:checksum]&.to_i
24
24
  # end
25
25
  #
26
- # def calculate_check_digit
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-check-digit identifier
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 check digit)
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: check-digit types first,
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 [check-digit rank, length specificity, registration order]
63
+ # @return [Array] frozen [checksum rank, length specificity, registration order]
64
64
  def detection_priority
65
65
  @detection_priority ||=
66
- [has_check_digit? ? 0 : 1, length_specificity, SecID.identifiers.index(self) || Float::INFINITY].freeze
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 check digit
93
- def has_check_digit?
94
- return @has_check_digit if defined?(@has_check_digit)
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
- @has_check_digit = ancestors.include?(SecID::Checkable)
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: 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) = components
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
  {}