sec_id 5.1.0 → 6.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +6 -0
  3. data/CHANGELOG.md +38 -0
  4. data/MIGRATION.md +101 -0
  5. data/README.md +262 -15
  6. data/lib/sec_id/active_model.rb +144 -0
  7. data/lib/sec_id/base.rb +41 -1
  8. data/lib/sec_id/bic/country_codes.rb +46 -0
  9. data/lib/sec_id/bic.rb +123 -0
  10. data/lib/sec_id/cei.rb +19 -7
  11. data/lib/sec_id/cfi/attribute_set.rb +49 -0
  12. data/lib/sec_id/cfi/classification.rb +107 -0
  13. data/lib/sec_id/cfi/field.rb +56 -0
  14. data/lib/sec_id/cfi/tables.rb +1033 -0
  15. data/lib/sec_id/cfi.rb +153 -208
  16. data/lib/sec_id/cik.rb +13 -0
  17. data/lib/sec_id/concerns/checkable.rb +2 -2
  18. data/lib/sec_id/concerns/generatable.rb +71 -0
  19. data/lib/sec_id/concerns/normalizable.rb +1 -0
  20. data/lib/sec_id/concerns/validatable.rb +14 -3
  21. data/lib/sec_id/cusip.rb +18 -7
  22. data/lib/sec_id/deep_freeze.rb +18 -0
  23. data/lib/sec_id/detector.rb +28 -7
  24. data/lib/sec_id/dti.rb +104 -0
  25. data/lib/sec_id/errors.rb +7 -0
  26. data/lib/sec_id/figi.rb +18 -0
  27. data/lib/sec_id/fisn.rb +31 -0
  28. data/lib/sec_id/iban/country_rules.rb +7 -6
  29. data/lib/sec_id/iban.rb +26 -1
  30. data/lib/sec_id/isin.rb +20 -15
  31. data/lib/sec_id/lei.rb +13 -0
  32. data/lib/sec_id/occ.rb +21 -0
  33. data/lib/sec_id/railtie.rb +14 -0
  34. data/lib/sec_id/scanner.rb +140 -0
  35. data/lib/sec_id/sedol.rb +16 -0
  36. data/lib/sec_id/valoren.rb +13 -0
  37. data/lib/sec_id/version.rb +2 -1
  38. data/lib/sec_id/wkn.rb +16 -0
  39. data/lib/sec_id.rb +112 -32
  40. data/sec_id.gemspec +11 -5
  41. data/sig/manifest.yaml +8 -0
  42. data/sig/sec_id/base.rbs +72 -0
  43. data/sig/sec_id/bic/country_codes.rbs +5 -0
  44. data/sig/sec_id/bic.rbs +30 -0
  45. data/sig/sec_id/cei.rbs +26 -0
  46. data/sig/sec_id/cfi/attribute_set.rbs +68 -0
  47. data/sig/sec_id/cfi/classification.rbs +23 -0
  48. data/sig/sec_id/cfi/field.rbs +337 -0
  49. data/sig/sec_id/cfi/tables.rbs +68 -0
  50. data/sig/sec_id/cfi.rbs +56 -0
  51. data/sig/sec_id/cik.rbs +19 -0
  52. data/sig/sec_id/concerns/checkable.rbs +41 -0
  53. data/sig/sec_id/concerns/generatable.rbs +26 -0
  54. data/sig/sec_id/concerns/normalizable.rbs +34 -0
  55. data/sig/sec_id/concerns/validatable.rbs +42 -0
  56. data/sig/sec_id/cusip.rbs +29 -0
  57. data/sig/sec_id/deep_freeze.rbs +6 -0
  58. data/sig/sec_id/detector.rbs +35 -0
  59. data/sig/sec_id/dti.rbs +27 -0
  60. data/sig/sec_id/errors.rbs +28 -0
  61. data/sig/sec_id/figi.rbs +31 -0
  62. data/sig/sec_id/fisn.rbs +24 -0
  63. data/sig/sec_id/iban/country_rules.rbs +10 -0
  64. data/sig/sec_id/iban.rbs +54 -0
  65. data/sig/sec_id/isin.rbs +40 -0
  66. data/sig/sec_id/lei.rbs +33 -0
  67. data/sig/sec_id/occ.rbs +45 -0
  68. data/sig/sec_id/scanner.rbs +46 -0
  69. data/sig/sec_id/sedol.rbs +34 -0
  70. data/sig/sec_id/valoren.rbs +29 -0
  71. data/sig/sec_id/version.rbs +3 -0
  72. data/sig/sec_id/wkn.rbs +15 -0
  73. data/sig/sec_id.rbs +58 -0
  74. metadata +57 -7
  75. data/lib/sec_id/concerns/identifier_metadata.rb +0 -56
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sec_id'
4
+
5
+ begin
6
+ require 'active_model'
7
+ rescue LoadError => e
8
+ # :nocov: ActiveModel is always loadable in the test suite
9
+ raise LoadError, 'sec_id/active_model requires ActiveModel, which could not be loaded. Add ' \
10
+ '`gem "activemodel"` to your Gemfile (or use this inside a Rails app) before ' \
11
+ "requiring 'sec_id/active_model'. (#{e.message})"
12
+ # :nocov:
13
+ end
14
+
15
+ # ActiveModel validator for securities identifiers, registered under the `sec_id` key.
16
+ #
17
+ # It validates a value against a single type (`type:`), an allowlist (`types:`), or any of the
18
+ # supported identifier types (no key). Validation is strict by default; separator-lenient
19
+ # canonicalization and specific failure reasons are opt-in via `normalize:` and `details:`.
20
+ #
21
+ # This file is loaded automatically inside Rails (via {SecID::Railtie}) and must be required
22
+ # explicitly (`require 'sec_id/active_model'`) in non-Rails ActiveModel stacks. It is never on
23
+ # the default `require 'sec_id'` path, so the gem keeps its zero runtime dependencies.
24
+ #
25
+ # @example Validate a single type
26
+ # validates :isin, sec_id: { type: :isin }
27
+ #
28
+ # @example Validate against an allowlist
29
+ # validates :ref, sec_id: { types: %i[isin cusip] }
30
+ #
31
+ # @example Validate against any supported type
32
+ # validates :ref, sec_id: true
33
+ class SecIdValidator < ActiveModel::EachValidator
34
+ # Built-in English default; `%{type_name}` is interpolated by ActiveModel/I18n (template
35
+ # token syntax is required here, hence the cop disable).
36
+ DEFAULT_MESSAGE = 'is not a valid %{type_name}' # rubocop:disable Style/FormatStringToken
37
+
38
+ # Validates the validator's own options at class-load time (fail-fast on misconfiguration).
39
+ #
40
+ # @return [void]
41
+ # @raise [ArgumentError] if both `type:` and `types:` are given, or a named type is unknown
42
+ def check_validity!
43
+ raise ArgumentError, 'Pass either :type or :types, not both' if options[:type] && options[:types]
44
+ raise ArgumentError, ':types cannot be empty' if options[:types] && configured_types.empty?
45
+
46
+ configured_types&.each { |type| SecID[type] }
47
+ end
48
+
49
+ # Validates `value` and, with `normalize: true`, rewrites it to canonical form on success.
50
+ #
51
+ # @param record [ActiveModel::Validations] the record being validated
52
+ # @param attribute [Symbol] the attribute being validated
53
+ # @param value [Object] the attribute value
54
+ # @return [void]
55
+ def validate_each(record, attribute, value)
56
+ return if valid_value?(record, attribute, value)
57
+
58
+ record.errors.add(
59
+ attribute, :sec_id,
60
+ message: options[:message] || detail_reason(value) || DEFAULT_MESSAGE,
61
+ type_name: human_type_name
62
+ )
63
+ end
64
+
65
+ private
66
+
67
+ # Strict by default; with `normalize: true`, separator-lenient with canonical write-back on success.
68
+ #
69
+ # @param record [ActiveModel::Validations] the record being validated
70
+ # @param attribute [Symbol] the attribute being validated
71
+ # @param value [Object] the attribute value
72
+ # @return [Boolean] whether the value is valid (and, in normalize mode, was rewritten)
73
+ def valid_value?(record, attribute, value)
74
+ return SecID.valid?(value, types: configured_types) unless options[:normalize]
75
+
76
+ canonical = candidate_types.lazy.filter_map { |type| normalize_via(type, value) }.first
77
+ return false unless canonical
78
+
79
+ record.public_send("#{attribute}=", canonical)
80
+ true
81
+ end
82
+
83
+ # @param type [Symbol] the identifier type to try
84
+ # @param value [Object] the attribute value
85
+ # @return [String, nil] the canonical form of `value` as `type`, or nil if it is not valid as `type`
86
+ def normalize_via(type, value)
87
+ SecID[type].normalize(value)
88
+ rescue SecID::Error
89
+ nil
90
+ end
91
+
92
+ # Concrete list of candidate types for the lenient path (never nil, unlike {#configured_types}).
93
+ #
94
+ # @return [Array<Symbol>]
95
+ def candidate_types
96
+ configured_types || SecID.identifiers.map { |klass| klass.short_name.downcase.to_sym }
97
+ end
98
+
99
+ # sec_id's specific failure reason, only when `details: true` and a single `type:` is set;
100
+ # nil otherwise (allowlist/agnostic has no single type to attribute a reason to — R12).
101
+ #
102
+ # @param value [Object] the attribute value
103
+ # @return [String, nil]
104
+ def detail_reason(value)
105
+ return unless options[:details] && options[:type]
106
+
107
+ type = options[:type].to_sym
108
+ return capture_reason(type, value) if options[:normalize]
109
+
110
+ SecID[type].validate(value).errors.messages.first
111
+ end
112
+
113
+ # @param type [Symbol] the single configured identifier type
114
+ # @param value [Object] the attribute value
115
+ # @return [String, nil] the message of the SecID::Error raised when normalizing `value` as `type`
116
+ def capture_reason(type, value)
117
+ SecID[type].normalize(value)
118
+ # :nocov: unreachable — only called after normalize already raised in valid_value?, kept as a safe fallback
119
+ nil
120
+ # :nocov:
121
+ rescue SecID::Error => e
122
+ e.message
123
+ end
124
+
125
+ # Configured types as symbols: `[type]` for a single type, the `types:` array for an allowlist,
126
+ # or `nil` when type-agnostic (any supported type).
127
+ #
128
+ # @return [Array<Symbol>, nil]
129
+ def configured_types
130
+ return @configured_types if defined?(@configured_types)
131
+
132
+ @configured_types =
133
+ if options[:type] then [options[:type].to_sym]
134
+ elsif options[:types] then Array(options[:types]).map(&:to_sym)
135
+ end
136
+ end
137
+
138
+ # The type's short name for single-type mode, or a generic label for allowlist/agnostic mode.
139
+ #
140
+ # @return [String]
141
+ def human_type_name
142
+ options[:type] ? SecID[options[:type].to_sym].short_name : 'securities identifier'
143
+ end
144
+ end
data/lib/sec_id/base.rb CHANGED
@@ -39,9 +39,9 @@ module SecID
39
39
  # end
40
40
  # end
41
41
  class Base
42
- include IdentifierMetadata
43
42
  include Normalizable
44
43
  include Validatable
44
+ include Generatable
45
45
 
46
46
  # @return [String] the original input after normalization (stripped and uppercased)
47
47
  attr_reader :full_id
@@ -49,9 +49,42 @@ module SecID
49
49
  # @return [String, nil] the main identifier portion (without check digit)
50
50
  attr_reader :identifier
51
51
 
52
+ class << self
53
+ # @return [String] the unqualified class name (e.g. "ISIN", "CUSIP")
54
+ def short_name = @short_name ||= name.split('::').last
55
+
56
+ # @return [String] the full human-readable standard name
57
+ def full_name = self::FULL_NAME
58
+
59
+ # @return [Integer, Range, Array<Integer>] the fixed length, valid length range, or discrete valid lengths
60
+ def id_length = self::ID_LENGTH
61
+
62
+ # Valid length values, for length-table indexing. Integer wraps to a
63
+ # one-element Array; Range and Array both yield their own elements.
64
+ #
65
+ # @return [Array<Integer>, Range]
66
+ def length_values = (v = self::ID_LENGTH).is_a?(Integer) ? [v] : v
67
+
68
+ # Specificity weight from ID_LENGTH: fewer valid lengths ranks more specific.
69
+ #
70
+ # @return [Integer]
71
+ def length_specificity = (v = self::ID_LENGTH).is_a?(Integer) ? 1 : v.size
72
+
73
+ # @return [String] a representative valid identifier string
74
+ def example = self::EXAMPLE
75
+
76
+ # @return [Boolean] true if this identifier type uses a check digit
77
+ def has_check_digit?
78
+ return @has_check_digit if defined?(@has_check_digit)
79
+
80
+ @has_check_digit = ancestors.include?(SecID::Checkable)
81
+ end
82
+ end
83
+
52
84
  # @api private
53
85
  def self.inherited(subclass)
54
86
  super
87
+ # Skip anonymous classes and classes outside the SecID namespace (e.g. in tests)
55
88
  SecID.__send__(:register_identifier, subclass) if subclass.name&.start_with?('SecID::')
56
89
  end
57
90
 
@@ -89,6 +122,13 @@ module SecID
89
122
  }
90
123
  end
91
124
 
125
+ # Returns a JSON-compatible hash representation.
126
+ #
127
+ # @return [Hash]
128
+ def as_json(*)
129
+ to_h
130
+ end
131
+
92
132
  protected
93
133
 
94
134
  # @return [String]
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SecID
4
+ class BIC < Base
5
+ # Frozen set of country codes accepted in a BIC's positions 5-6.
6
+ #
7
+ # The set is the ISO 3166-1 alpha-2 officially-assigned codes, extended with
8
+ # SWIFT-recognized codes that are not (yet) in ISO 3166-1 — currently `XK`
9
+ # (Kosovo), which SWIFT assigns BICs under.
10
+ #
11
+ # @api private
12
+ # @see https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
13
+ # rubocop:disable Metrics/CollectionLiteralLength
14
+ COUNTRY_CODES = Set.new(
15
+ %w[
16
+ AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ
17
+ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ
18
+ CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ
19
+ DE DJ DK DM DO DZ
20
+ EC EE EG EH ER ES ET
21
+ FI FJ FK FM FO FR
22
+ GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY
23
+ HK HM HN HR HT HU
24
+ ID IE IL IM IN IO IQ IR IS IT
25
+ JE JM JO JP
26
+ KE KG KH KI KM KN KP KR KW KY KZ
27
+ LA LB LC LI LK LR LS LT LU LV LY
28
+ MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ
29
+ NA NC NE NF NG NI NL NO NP NR NU NZ
30
+ OM
31
+ PA PE PF PG PH PK PL PM PN PR PS PT PW PY
32
+ QA
33
+ RE RO RS RU RW
34
+ SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ
35
+ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ
36
+ UA UG UM US UY UZ
37
+ VA VC VE VG VI VN VU
38
+ WF WS
39
+ YE YT
40
+ ZA ZM ZW
41
+ XK
42
+ ]
43
+ ).freeze
44
+ # rubocop:enable Metrics/CollectionLiteralLength
45
+ end
46
+ end
data/lib/sec_id/bic.rb ADDED
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'bic/country_codes'
4
+
5
+ module SecID
6
+ # Business Identifier Code (BIC / SWIFT code) - an international standard for
7
+ # identifying financial and non-financial institutions (ISO 9362).
8
+ #
9
+ # Format: 4-letter institution code + 2-letter country code + 2-alphanumeric
10
+ # location code, optionally followed by a 3-alphanumeric branch code (BIC8 or BIC11).
11
+ #
12
+ # Validation confirms the structure and that the embedded country code is a real
13
+ # ISO 3166-1 / SWIFT-recognized country. It does *not* verify that the institution,
14
+ # location, or branch corresponds to a registered SWIFT participant — that requires
15
+ # the licensed SWIFT registry.
16
+ #
17
+ # @see https://en.wikipedia.org/wiki/ISO_9362
18
+ #
19
+ # @example Validate a BIC
20
+ # SecID::BIC.valid?('DEUTDEFF') #=> true (BIC8)
21
+ # SecID::BIC.valid?('DEUTDEFF500') #=> true (BIC11)
22
+ #
23
+ # @example Access BIC components
24
+ # bic = SecID::BIC.new('DEUTDEFF500')
25
+ # bic.bank_code #=> "DEUT"
26
+ # bic.country_code #=> "DE"
27
+ # bic.location_code #=> "FF"
28
+ # bic.branch_code #=> "500"
29
+ class BIC < Base
30
+ # Human-readable name of the standard.
31
+ FULL_NAME = 'Business Identifier Code'
32
+ # Valid length(s) of a normalized identifier.
33
+ ID_LENGTH = [8, 11].freeze
34
+ # A representative valid identifier.
35
+ EXAMPLE = 'DEUTDEFF500'
36
+ # Pattern matching the identifier's permitted character set.
37
+ VALID_CHARS_REGEX = /\A[A-Z0-9]+\z/
38
+
39
+ # Regular expression for parsing BIC components.
40
+ # The optional all-or-nothing branch code makes the length exactly 8 or 11.
41
+ ID_REGEX = /\A
42
+ (?<bank_code>[A-Z]{4})
43
+ (?<country_code>[A-Z]{2})
44
+ (?<location_code>[A-Z0-9]{2})
45
+ (?<branch_code>[A-Z0-9]{3})?
46
+ \z/x
47
+
48
+ # Returns the sorted array of all recognized country codes.
49
+ #
50
+ # @return [Array<String>]
51
+ def self.countries
52
+ @countries ||= COUNTRY_CODES.to_a.sort.freeze
53
+ end
54
+
55
+ # @return [String, nil] the 4-letter institution (bank) code
56
+ attr_reader :bank_code
57
+
58
+ # @return [String, nil] the 2-letter ISO 3166-1 country code
59
+ attr_reader :country_code
60
+
61
+ # @return [String, nil] the 2-alphanumeric location code
62
+ attr_reader :location_code
63
+
64
+ # @return [String, nil] the 3-alphanumeric branch code, or nil for a BIC8
65
+ attr_reader :branch_code
66
+
67
+ # @param bic [String] the BIC string to parse
68
+ def initialize(bic)
69
+ bic_parts = parse(bic)
70
+ @bank_code = bic_parts[:bank_code]
71
+ @country_code = bic_parts[:country_code]
72
+ @location_code = bic_parts[:location_code]
73
+ @branch_code = bic_parts[:branch_code]
74
+ @identifier = "#{@bank_code}#{@country_code}#{@location_code}#{@branch_code}" if @bank_code
75
+ end
76
+
77
+ # Generates a random BIC: 4 letters + a recognized country + 2 alphanumerics,
78
+ # with a 3-alphanumeric branch present about half the time (BIC8 or BIC11).
79
+ #
80
+ # @param random [Random] source of randomness
81
+ # @return [String] a valid 8- or 11-character BIC
82
+ def self.generate_body(random)
83
+ bank = random_string(ALPHA, 4, random: random)
84
+ country = countries.sample(random: random)
85
+ location = random_string(ALPHANUMERIC, 2, random: random)
86
+ branch = random.rand(2).zero? ? '' : random_string(ALPHANUMERIC, 3, random: random)
87
+ "#{bank}#{country}#{location}#{branch}"
88
+ end
89
+ private_class_method :generate_body
90
+
91
+ private
92
+
93
+ # @return [Hash]
94
+ def components = { bank_code:, country_code:, location_code:, branch_code: }
95
+
96
+ # @return [Boolean]
97
+ def valid_format?
98
+ return false unless identifier
99
+
100
+ recognized_country?
101
+ end
102
+
103
+ # @return [Array<Symbol>]
104
+ def detect_errors
105
+ return [:invalid_country] if identifier && !recognized_country?
106
+
107
+ super
108
+ end
109
+
110
+ # @param code [Symbol]
111
+ # @return [String]
112
+ def validation_message(code)
113
+ return "Country code '#{country_code}' is not a recognized ISO 3166 / SWIFT country" if code == :invalid_country
114
+
115
+ super
116
+ end
117
+
118
+ # @return [Boolean]
119
+ def recognized_country?
120
+ COUNTRY_CODES.include?(country_code)
121
+ end
122
+ end
123
+ end
data/lib/sec_id/cei.rb CHANGED
@@ -13,9 +13,13 @@ module SecID
13
13
  class CEI < Base
14
14
  include Checkable
15
15
 
16
+ # Human-readable name of the standard.
16
17
  FULL_NAME = 'CUSIP Entity Identifier'
18
+ # Valid length(s) of a normalized identifier.
17
19
  ID_LENGTH = 10
20
+ # A representative valid identifier.
18
21
  EXAMPLE = 'A0BCDEFGH1'
22
+ # Pattern matching the identifier's permitted character set.
19
23
  VALID_CHARS_REGEX = /\A[A-Z0-9]+\z/
20
24
 
21
25
  # Regular expression for parsing CEI components.
@@ -46,18 +50,26 @@ module SecID
46
50
  @check_digit = cei_parts[:check_digit]&.to_i
47
51
  end
48
52
 
49
- private
50
-
51
- # @return [Hash]
52
- def components = { prefix:, numeric:, entity_id:, check_digit: }
53
-
54
- public
55
-
56
53
  # @return [Integer] the calculated check digit (0-9)
57
54
  # @raise [InvalidFormatError] if the CEI format is invalid
58
55
  def calculate_check_digit
59
56
  validate_format_for_calculation!
60
57
  mod10(luhn_sum_double_add_double(reversed_digits_single(identifier)))
61
58
  end
59
+
60
+ # Generates a random CEI body: 1 letter + 1 digit + 7 alphanumeric characters.
61
+ #
62
+ # @param random [Random] source of randomness
63
+ # @return [String] a 9-character CEI body without check digit
64
+ def self.generate_body(random)
65
+ "#{random_string(ALPHA, 1, random: random)}#{random_string(DIGITS, 1, random: random)}" \
66
+ "#{random_string(ALPHANUMERIC, 7, random: random)}"
67
+ end
68
+ private_class_method :generate_body
69
+
70
+ private
71
+
72
+ # @return [Hash]
73
+ def components = { prefix:, numeric:, entity_id:, check_digit: }
62
74
  end
63
75
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SecID
4
+ class CFI < Base
5
+ # The ordered collection of decoded attribute {Field}s. Enumerable over the
6
+ # fields; also answers each present meaning as a reader method (e.g.
7
+ # `#voting_right`) and via `#[]` (nil-safe). Built by {Classification}.
8
+ class AttributeSet
9
+ include Enumerable
10
+
11
+ # @param fields [Array<Field>] one per non-N/A position, in order
12
+ def initialize(fields)
13
+ @fields = fields
14
+ fields.each { |field| define_singleton_method(field.meaning) { field } }
15
+ freeze
16
+ end
17
+
18
+ # @yieldparam field [Field]
19
+ # @return [Enumerator, self]
20
+ def each(&block)
21
+ return to_enum(:each) unless block
22
+
23
+ @fields.each(&block)
24
+ self
25
+ end
26
+
27
+ # @param meaning [Symbol]
28
+ # @return [Field, nil]
29
+ def [](meaning)
30
+ find { |field| field.meaning == meaning }
31
+ end
32
+
33
+ # @return [Boolean]
34
+ def empty?
35
+ @fields.empty?
36
+ end
37
+
38
+ # @return [Hash{Symbol => Hash}]
39
+ def to_h
40
+ @fields.to_h { |field| [field.meaning, field.to_h] }
41
+ end
42
+
43
+ # @return [Hash{Symbol => Hash}]
44
+ def as_json(*)
45
+ to_h
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SecID
4
+ class CFI < Base
5
+ # The decoded ISO 10962:2021 classification of a valid CFI, built by
6
+ # {CFI#decode}. A frozen value object exposing the category, group, and each
7
+ # attribute as {Field} objects, plus `#to_s` and `#to_h`/`#as_json`.
8
+ #
9
+ # @example Decode an equity CFI
10
+ # c = SecID::CFI.new('ESVUFR').decode
11
+ # c.category.name #=> :equity
12
+ # c.category.equity? #=> true
13
+ # c.group.label #=> "Common/Ordinary shares"
14
+ # c.attributes.voting_right.voting? #=> true
15
+ # c.attributes.payment_status.label #=> "Fully paid"
16
+ # c.to_s #=> "Equities / Common/Ordinary shares: Voting, ..."
17
+ class Classification
18
+ # @return [Field] the category field
19
+ attr_reader :category
20
+
21
+ # @return [Field] the group field
22
+ attr_reader :group
23
+
24
+ # @return [AttributeSet] the decoded attribute fields
25
+ attr_reader :attributes
26
+
27
+ # @param category_code [String]
28
+ # @param group_code [String]
29
+ # @param letters [Array<String>] the four attribute letters (positions 3-6)
30
+ def initialize(category_code, group_code, letters)
31
+ @category = build_category(category_code)
32
+ @group = build_group(category_code, group_code)
33
+ @attributes = build_attributes(category_code, group_code, letters)
34
+ freeze
35
+ end
36
+
37
+ # Renders a human-readable classification string from the ISO labels.
38
+ #
39
+ # @return [String]
40
+ def to_s
41
+ values = attributes.map(&:label).join(', ')
42
+ head = "#{category.label} / #{group.label}"
43
+ values.empty? ? head : "#{head}: #{values}"
44
+ end
45
+
46
+ # @return [Hash{Symbol => Object}]
47
+ def to_h
48
+ { category: category.to_h, group: group.to_h, attributes: attributes.to_h }
49
+ end
50
+
51
+ # @return [Hash{Symbol => Object}]
52
+ def as_json(*)
53
+ to_h
54
+ end
55
+
56
+ private
57
+
58
+ # @param code [String]
59
+ # @return [Field]
60
+ def build_category(code)
61
+ name, label = Tables::CATEGORIES.fetch(code)
62
+ domain = Tables::CATEGORIES.each_value.map(&:first)
63
+ Field.new(code, name, label, domain)
64
+ end
65
+
66
+ # @param category_code [String]
67
+ # @param code [String]
68
+ # @return [Field]
69
+ def build_group(category_code, code)
70
+ group = Tables.group(category_code, code)
71
+ domain = Tables::GROUPS.fetch(category_code).each_value.map { |definition| definition[:symbol] }
72
+ Field.new(code, group[:symbol], group[:label], domain)
73
+ end
74
+
75
+ # @param category_code [String]
76
+ # @param group_code [String]
77
+ # @param letters [Array<String>]
78
+ # @return [AttributeSet]
79
+ def build_attributes(category_code, group_code, letters)
80
+ positions = Tables.group(category_code, group_code)[:attributes]
81
+ fields = positions.zip(letters).filter_map do |position, letter|
82
+ build_attribute(position, letter) if position
83
+ end
84
+ AttributeSet.new(fields)
85
+ end
86
+
87
+ # @param position [Array(Symbol, Hash)] a [meaning, value_map] pair
88
+ # @param letter [String]
89
+ # @return [Field]
90
+ def build_attribute(position, letter)
91
+ meaning, value_map = position
92
+ name, label = decode_value(value_map, letter)
93
+ domain = value_map.each_value.map(&:first) + [:not_applicable]
94
+ Field.new(letter, name, label, domain, meaning: meaning)
95
+ end
96
+
97
+ # @param value_map [Hash{String => Array}]
98
+ # @param letter [String]
99
+ # @return [Array(Symbol, String)] the value symbol and its ISO label
100
+ def decode_value(value_map, letter)
101
+ return [:not_applicable, 'Not applicable'] if letter == 'X'
102
+
103
+ value_map.fetch(letter)
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SecID
4
+ class CFI < Base
5
+ # A single decoded CFI position: the category, the group, or one attribute.
6
+ # Carries its raw CFI letter (`#code`), semantic symbol (`#name`), ISO label
7
+ # (`#label`), and — for attributes — the group meaning it answers
8
+ # (`#meaning`). Defines a `<name>?` predicate for every symbol in its domain,
9
+ # so `category.equity?` is answerable while an out-of-domain predicate such
10
+ # as `category.voting?` raises `NoMethodError`.
11
+ #
12
+ # Built by {Classification}; also usable on its own.
13
+ class Field
14
+ # @return [String] the raw CFI letter (e.g. "E", "V")
15
+ attr_reader :code
16
+
17
+ # @return [Symbol] the semantic symbol (e.g. :equity, :voting)
18
+ attr_reader :name
19
+
20
+ # @return [String] the authoritative ISO label
21
+ attr_reader :label
22
+
23
+ # @return [Symbol, nil] the group meaning for attribute fields, nil for category/group
24
+ attr_reader :meaning
25
+
26
+ # @param code [String]
27
+ # @param name [Symbol]
28
+ # @param label [String]
29
+ # @param domain [Array<Symbol>] the symbols this field may hold; one `<symbol>?` predicate per entry
30
+ # @param meaning [Symbol, nil]
31
+ def initialize(code, name, label, domain, meaning: nil)
32
+ @code = code
33
+ @name = name
34
+ @label = label
35
+ @meaning = meaning
36
+ domain.each { |symbol| define_singleton_method(:"#{symbol}?") { @name == symbol } }
37
+ freeze
38
+ end
39
+
40
+ # @return [String] the ISO label
41
+ def to_s
42
+ label
43
+ end
44
+
45
+ # @return [Hash{Symbol => Object}]
46
+ def to_h
47
+ { code: code, name: name, label: label }
48
+ end
49
+
50
+ # @return [Hash{Symbol => Object}]
51
+ def as_json(*)
52
+ to_h
53
+ end
54
+ end
55
+ end
56
+ end