Class: ActiveSanction::Identifier

Inherits:
Object
  • Object
show all
Extended by:
T::Sig
Defined in:
lib/active_sanction/identifier.rb

Overview

A document number a sanctions list published for an entity: passport, national ID, tax number, company registration. It is the highest-value signal the matcher has -- an exact passport match is near-decisive in the scorer (#32), where a name match never is.

ActiveSanction::Identifier.new(
kind:       :passport,
value:      "AB-123 456",
country:    "Egypt",
issued_on:  PartialDate.parse("2004-06-01"),
expires_on: PartialDate.parse("2009-05-31"),
note:       "expired"
)

value is the one required field: an identifier with no number is not an identifier. Everything else is optional, because OFAC's remarks often give just Passport 123456 (Egypt) and nothing more.

The published string is kept verbatim -- it is what gets shown back to a user justifying a hit -- while comparison runs on #normalized_value, since two governments transcribing one passport rarely agree on its punctuation. Instances are frozen on construction and compare by value.

Constant Summary collapse

KINDS =

The UN publishes TYPE_OF_DOCUMENT as free text ("Passport", "National Identification Number"), so adapters map onto these rather than passing a source's own vocabulary through. :other is a real answer, not a failure: a document we cannot classify still matches on its number.

T.let(%i[passport national_id tax_id registration_number other].freeze, T::Array[Symbol])

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(value:, kind: :other, country: nil, issued_on: nil, expires_on: nil, note: nil) ⇒ void

kind defaults to :other because a number we cannot classify is still worth matching on -- OFAC remarks carry plenty of them.

Untyped on purpose, and the same choice Entity makes: all six are the publisher's own text arriving as whatever the parser made of it. The two dates additionally accept a PartialDate, a #to_h hash or anything PartialDate.parse reads -- see #date_or_nil.

Parameters:

  • value (T.untyped)
  • kind (T.untyped) (defaults to: :other)
  • country (T.untyped) (defaults to: nil)
  • issued_on (T.untyped) (defaults to: nil)
  • expires_on (T.untyped) (defaults to: nil)
  • note (T.untyped) (defaults to: nil)


105
106
107
108
109
110
111
112
113
114
# File 'lib/active_sanction/identifier.rb', line 105

def initialize(value:, kind: :other, country: nil, issued_on: nil, expires_on: nil, note: nil)
  @value = T.let(value!(value), String)
  @normalized_value = T.let(-@value.downcase.gsub(INSIGNIFICANT, ""), String)
  @kind = T.let(kind!(kind), Symbol)
  @country = T.let(string_or_nil(country), T.nilable(String))
  @issued_on = T.let(date_or_nil(:issued_on, issued_on), T.nilable(PartialDate))
  @expires_on = T.let(date_or_nil(:expires_on, expires_on), T.nilable(PartialDate))
  @note = T.let(string_or_nil(note), T.nilable(String))
  freeze
end

Instance Attribute Details

#country ⇒ String? (readonly)

Returns:

  • (String, nil)


64
65
66
# File 'lib/active_sanction/identifier.rb', line 64

def country
  @country
end

#expires_on ⇒ PartialDate? (readonly)

Returns:



70
71
72
# File 'lib/active_sanction/identifier.rb', line 70

def expires_on
  @expires_on
end

#issued_on ⇒ PartialDate? (readonly)

Returns:



67
68
69
# File 'lib/active_sanction/identifier.rb', line 67

def issued_on
  @issued_on
end

#kind ⇒ Symbol (readonly)

Returns:

  • (Symbol)


56
57
58
# File 'lib/active_sanction/identifier.rb', line 56

def kind
  @kind
end

#normalized_value ⇒ String (readonly)

What comparison actually runs on -- see #== below.

Returns:

  • (String)


77
78
79
# File 'lib/active_sanction/identifier.rb', line 77

def normalized_value
  @normalized_value
end

#note ⇒ String? (readonly)

Returns:

  • (String, nil)


73
74
75
# File 'lib/active_sanction/identifier.rb', line 73

def note
  @note
end

#value ⇒ String (readonly)

The published string, verbatim: it is what gets shown back to whoever has to justify a hit.

Returns:

  • (String)


61
62
63
# File 'lib/active_sanction/identifier.rb', line 61

def value
  @value
end

Class Method Details

.from_h(hash) ⇒ T.attached_class

Rebuilds an identifier from #to_h output. Accepts string keys, and dates as the hashes JSON leaves behind, so a record survives a round-trip through storage (#24) without a separate coercion step.

Parameters:

  • hash (T.untyped)

Returns:

  • (T.attached_class)

Raises:



83
84
85
86
87
88
89
90
91
92
# File 'lib/active_sanction/identifier.rb', line 83

def self.from_h(hash)
  attributes = hash.to_h.transform_keys(&:to_sym)
  unknown = attributes.keys - MEMBERS
  raise InvalidArgument, "unknown Identifier attribute(s): #{unknown.join(", ")}" if unknown.any?

  # `new(**hash)` past a required keyword parameter is one of the few
  # things Sorbet cannot check statically. #initialize validates what
  # arrives, which is where a bad round-trip is caught.
  T.unsafe(self).new(**attributes)
end

Instance Method Details

#==(other) ⇒ Boolean Also known as: eql?

Equality is the comparison the acceptance criteria asks for: AB-123 456 and ab123456 are one passport written down twice, and de-duplicating the same document across OFAC and the UN depends on saying so. Dates and notes stay out of the key -- publishers report them inconsistently, and letting them split one document into two records would defeat the dedup this exists for. Entity (#4) still compares its members through #to_h, so nothing here hides a differing published string from a record diff.

Parameters:

  • other (T.untyped)

Returns:

  • (Boolean)


142
143
144
145
146
# File 'lib/active_sanction/identifier.rb', line 142

def ==(other)
  return false unless other.instance_of?(self.class)

  comparison_key == other.comparison_key
end

#comparison_key ⇒ Array<(Symbol, String, [String, nil])> (protected)

Country is folded rather than dropped: two passports with the same number from different countries are different documents.

Returns:

  • (Array<(Symbol, String, [String, nil])>)


164
165
166
# File 'lib/active_sanction/identifier.rb', line 164

def comparison_key
  [kind, normalized_value, country&.downcase]
end

#hash ⇒ Integer

Returns:

  • (Integer)


150
151
152
# File 'lib/active_sanction/identifier.rb', line 150

def hash
  [self.class, comparison_key].hash
end

#inspect ⇒ String

Returns:

  • (String)


155
156
157
# File 'lib/active_sanction/identifier.rb', line 155

def inspect
  "#<#{self.class} #{kind.inspect} #{value.inspect}#{" country=#{country.inspect}" if country}>"
end

#passport? ⇒ Boolean

Returns:

  • (Boolean)


117
# File 'lib/active_sanction/identifier.rb', line 117

def passport? = kind == :passport

#to_h ⇒ Hash{Symbol => T.untyped}

Returns:

  • (Hash{Symbol => T.untyped})


120
121
122
123
124
125
126
127
128
129
# File 'lib/active_sanction/identifier.rb', line 120

def to_h
  {
    kind: kind,
    value: value,
    country: country,
    issued_on: issued_on&.to_h,
    expires_on: expires_on&.to_h,
    note: note
  }
end

#to_s ⇒ String

Returns:

  • (String)


132
# File 'lib/active_sanction/identifier.rb', line 132

def to_s = value