elia_ruby 0.1.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +202 -0
- data/lib/elia/mcc/active_model_validator.rb +50 -0
- data/lib/elia/mcc/category.rb +107 -0
- data/lib/elia/mcc/code.rb +242 -0
- data/lib/elia/mcc/collection.rb +276 -0
- data/lib/elia/mcc/configuration.rb +85 -0
- data/lib/elia/mcc/data/.gitkeep +0 -0
- data/lib/elia/mcc/data/mcc_codes.yml +12809 -0
- data/lib/elia/mcc/data/ranges.yml +126 -0
- data/lib/elia/mcc/data/risk_categories.yml +293 -0
- data/lib/elia/mcc/errors.rb +48 -0
- data/lib/elia/mcc/railtie.rb +36 -0
- data/lib/elia/mcc/range.rb +158 -0
- data/lib/elia/mcc/serializer.rb +89 -0
- data/lib/elia/mcc/validator.rb +102 -0
- data/lib/elia/mcc/version.rb +7 -0
- data/lib/elia/mcc.rb +65 -0
- data/lib/elia_ruby/version.rb +5 -0
- data/lib/elia_ruby.rb +35 -0
- metadata +98 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "active_support/core_ext/enumerable"
|
|
5
|
+
|
|
6
|
+
module Elia
|
|
7
|
+
module Mcc
|
|
8
|
+
# Provides data loading, caching, and query functionality for MCC codes
|
|
9
|
+
#
|
|
10
|
+
# The Collection class is the main workhorse that loads YAML data files,
|
|
11
|
+
# caches the results, and provides query methods for searching and filtering.
|
|
12
|
+
class Collection
|
|
13
|
+
# @return [Array<Code>] all loaded MCC codes
|
|
14
|
+
attr_reader :codes
|
|
15
|
+
|
|
16
|
+
# @return [Array<Range>] all loaded ISO 18245 ranges
|
|
17
|
+
attr_reader :ranges
|
|
18
|
+
|
|
19
|
+
# @return [Array<Category>] all loaded risk categories
|
|
20
|
+
attr_reader :categories
|
|
21
|
+
|
|
22
|
+
def initialize
|
|
23
|
+
@codes = []
|
|
24
|
+
@ranges = []
|
|
25
|
+
@categories = []
|
|
26
|
+
@loaded = false
|
|
27
|
+
@mutex = Mutex.new
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns all MCC codes, loading them if necessary
|
|
31
|
+
#
|
|
32
|
+
# @return [Array<Code>] all registered codes
|
|
33
|
+
def all
|
|
34
|
+
ensure_loaded!
|
|
35
|
+
@codes
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Finds an MCC code by its numeric value
|
|
39
|
+
#
|
|
40
|
+
# @param code [String, Integer] the code to find
|
|
41
|
+
# @return [Code, nil] the matching code or nil
|
|
42
|
+
def find(code)
|
|
43
|
+
ensure_loaded!
|
|
44
|
+
normalized = normalize_code(code)
|
|
45
|
+
@codes_index ||= @codes.index_by(&:mcc)
|
|
46
|
+
@codes_index[normalized]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Finds an MCC code or raises an error
|
|
50
|
+
#
|
|
51
|
+
# @param code [String, Integer] the code to find
|
|
52
|
+
# @return [Code] the matching code
|
|
53
|
+
# @raise [NotFound] if the code is not found
|
|
54
|
+
def find!(code)
|
|
55
|
+
find(code) || raise(NotFound, code)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Bracket accessor for finding codes
|
|
59
|
+
#
|
|
60
|
+
# @param code [String, Integer] the code to find
|
|
61
|
+
# @return [Code, nil] the matching code or nil
|
|
62
|
+
def [](code)
|
|
63
|
+
find(code)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Filters codes by attribute conditions
|
|
67
|
+
#
|
|
68
|
+
# @param conditions [Hash] attribute conditions to match
|
|
69
|
+
# @return [Array<Code>] matching codes
|
|
70
|
+
def where(conditions = {})
|
|
71
|
+
ensure_loaded!
|
|
72
|
+
result = @codes
|
|
73
|
+
|
|
74
|
+
conditions.each do |attr, value|
|
|
75
|
+
result = result.select do |code|
|
|
76
|
+
code_value = code.respond_to?(attr) ? code.public_send(attr) : nil
|
|
77
|
+
case value
|
|
78
|
+
when Regexp
|
|
79
|
+
code_value.to_s.match?(value)
|
|
80
|
+
when Array
|
|
81
|
+
value.include?(code_value)
|
|
82
|
+
else
|
|
83
|
+
code_value == value
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
result
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Returns codes within the given ISO 18245 range
|
|
92
|
+
#
|
|
93
|
+
# @param range_name [String, Symbol] the name of the range
|
|
94
|
+
# @return [Array<Code>] codes in the range
|
|
95
|
+
def in_range(range_name)
|
|
96
|
+
ensure_loaded!
|
|
97
|
+
range = @ranges.find { |r| r.name.downcase == range_name.to_s.downcase }
|
|
98
|
+
return [] unless range
|
|
99
|
+
|
|
100
|
+
@codes.select { |code| range.include?(code.mcc) }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Searches for codes matching the given query across all description fields
|
|
104
|
+
# Case-insensitive fuzzy matching
|
|
105
|
+
#
|
|
106
|
+
# @param query [String] the search query
|
|
107
|
+
# @return [Array<Code>] matching codes
|
|
108
|
+
def search(query)
|
|
109
|
+
ensure_loaded!
|
|
110
|
+
return @codes if query.to_s.strip.empty?
|
|
111
|
+
|
|
112
|
+
query_downcase = query.to_s.downcase
|
|
113
|
+
|
|
114
|
+
@codes.select do |code|
|
|
115
|
+
# Search across all description fields and code
|
|
116
|
+
searchable_text = [
|
|
117
|
+
code.mcc,
|
|
118
|
+
code.iso_description,
|
|
119
|
+
code.usda_description,
|
|
120
|
+
code.stripe_description,
|
|
121
|
+
code.stripe_code,
|
|
122
|
+
code.visa_description,
|
|
123
|
+
code.visa_clearing_name,
|
|
124
|
+
code.mastercard_description,
|
|
125
|
+
code.amex_description,
|
|
126
|
+
code.alipay_description,
|
|
127
|
+
code.irs_description,
|
|
128
|
+
].compact.join(" ")
|
|
129
|
+
|
|
130
|
+
searchable_text.downcase.include?(query_downcase)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Returns codes in the given category
|
|
135
|
+
#
|
|
136
|
+
# @param category_id [Symbol, String] the category identifier
|
|
137
|
+
# @return [Array<Code>] codes in the category
|
|
138
|
+
def in_category(category_id)
|
|
139
|
+
ensure_loaded!
|
|
140
|
+
category = @categories.find { |c| c.id == category_id.to_sym }
|
|
141
|
+
return [] unless category
|
|
142
|
+
|
|
143
|
+
@codes.select { |code| category.include?(code.mcc) }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Returns all loaded ranges
|
|
147
|
+
#
|
|
148
|
+
# @return [Array<Range>] all ranges
|
|
149
|
+
def all_ranges
|
|
150
|
+
ensure_loaded!
|
|
151
|
+
@ranges
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Returns all loaded categories
|
|
155
|
+
#
|
|
156
|
+
# @return [Array<Category>] all categories
|
|
157
|
+
def all_categories
|
|
158
|
+
ensure_loaded!
|
|
159
|
+
@categories
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Returns whether the given code is valid (exists in the collection)
|
|
163
|
+
#
|
|
164
|
+
# @param code [String, Integer] the code to check
|
|
165
|
+
# @return [Boolean] true if the code exists
|
|
166
|
+
def valid?(code)
|
|
167
|
+
!find(code).nil?
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
alias exists? valid?
|
|
171
|
+
|
|
172
|
+
# Returns the count of loaded codes
|
|
173
|
+
#
|
|
174
|
+
# @return [Integer] the number of codes
|
|
175
|
+
def count
|
|
176
|
+
all.size
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
alias size count
|
|
180
|
+
|
|
181
|
+
# Reloads all data from the YAML files
|
|
182
|
+
#
|
|
183
|
+
# @return [self]
|
|
184
|
+
def reload!
|
|
185
|
+
@mutex.synchronize do
|
|
186
|
+
@loaded = false
|
|
187
|
+
@codes = []
|
|
188
|
+
@ranges = []
|
|
189
|
+
@categories = []
|
|
190
|
+
@codes_index = nil
|
|
191
|
+
end
|
|
192
|
+
ensure_loaded!
|
|
193
|
+
self
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
private
|
|
197
|
+
|
|
198
|
+
# Ensures data is loaded, loading it if necessary
|
|
199
|
+
def ensure_loaded!
|
|
200
|
+
return if @loaded && Elia::Mcc.configuration.cache_enabled
|
|
201
|
+
|
|
202
|
+
@mutex.synchronize do
|
|
203
|
+
return if @loaded && Elia::Mcc.configuration.cache_enabled
|
|
204
|
+
|
|
205
|
+
load_data!
|
|
206
|
+
@loaded = true
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Loads all data from YAML files
|
|
211
|
+
def load_data!
|
|
212
|
+
data_path = Elia::Mcc.configuration.data_path
|
|
213
|
+
|
|
214
|
+
load_codes!(File.join(data_path, "mcc_codes.yml"))
|
|
215
|
+
load_ranges!(File.join(data_path, "ranges.yml"))
|
|
216
|
+
load_categories!(File.join(data_path, "risk_categories.yml"))
|
|
217
|
+
|
|
218
|
+
@codes_index = nil # Reset index after loading
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Loads MCC codes from the YAML file
|
|
222
|
+
#
|
|
223
|
+
# @param file_path [String] path to the YAML file
|
|
224
|
+
def load_codes!(file_path)
|
|
225
|
+
data = load_yaml(file_path)
|
|
226
|
+
@codes = data.map { |attrs| Code.new(attrs) }
|
|
227
|
+
rescue StandardError => e
|
|
228
|
+
raise DataLoadError.new(file_path, e)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Loads ISO 18245 ranges from the YAML file
|
|
232
|
+
#
|
|
233
|
+
# @param file_path [String] path to the YAML file
|
|
234
|
+
def load_ranges!(file_path)
|
|
235
|
+
data = load_yaml(file_path)
|
|
236
|
+
@ranges = data.map { |attrs| Range.new(attrs) }
|
|
237
|
+
rescue StandardError => e
|
|
238
|
+
raise DataLoadError.new(file_path, e)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Loads risk categories from the YAML file
|
|
242
|
+
#
|
|
243
|
+
# @param file_path [String] path to the YAML file
|
|
244
|
+
def load_categories!(file_path)
|
|
245
|
+
data = load_yaml(file_path)
|
|
246
|
+
|
|
247
|
+
@categories = data.map do |id, attrs|
|
|
248
|
+
Category.new(
|
|
249
|
+
id: id,
|
|
250
|
+
name: attrs["name"],
|
|
251
|
+
description: attrs["description"],
|
|
252
|
+
codes: attrs["codes"]
|
|
253
|
+
)
|
|
254
|
+
end
|
|
255
|
+
rescue StandardError => e
|
|
256
|
+
raise DataLoadError.new(file_path, e)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Loads and parses a YAML file
|
|
260
|
+
#
|
|
261
|
+
# @param file_path [String] path to the YAML file
|
|
262
|
+
# @return [Hash, Array] the parsed YAML data
|
|
263
|
+
def load_yaml(file_path)
|
|
264
|
+
YAML.load_file(file_path, permitted_classes: [Symbol])
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Normalizes a code value to a 4-digit string
|
|
268
|
+
#
|
|
269
|
+
# @param value [String, Integer] the value to normalize
|
|
270
|
+
# @return [String] the normalized 4-digit string
|
|
271
|
+
def normalize_code(value)
|
|
272
|
+
value.to_s.strip.rjust(4, "0")
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elia
|
|
4
|
+
module Mcc
|
|
5
|
+
# Configuration class for the Mcc module
|
|
6
|
+
#
|
|
7
|
+
# Provides settings for data file paths, caching behavior,
|
|
8
|
+
# description source preference, and other customization options.
|
|
9
|
+
class Configuration
|
|
10
|
+
# Valid description sources from the MCC data
|
|
11
|
+
DESCRIPTION_SOURCES = %i[
|
|
12
|
+
iso usda stripe visa mastercard amex alipay irs
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
# @return [Symbol] the default source for description lookups
|
|
16
|
+
attr_accessor :default_description_source
|
|
17
|
+
|
|
18
|
+
# @return [Boolean] whether to include reserved MCC ranges in queries
|
|
19
|
+
attr_accessor :include_reserved_ranges
|
|
20
|
+
|
|
21
|
+
# @return [Boolean] whether to enable caching of loaded data
|
|
22
|
+
attr_accessor :cache_enabled
|
|
23
|
+
|
|
24
|
+
# @return [String] path to the directory containing MCC data files
|
|
25
|
+
attr_accessor :data_path
|
|
26
|
+
|
|
27
|
+
# @return [Logger, nil] optional logger for debugging
|
|
28
|
+
attr_accessor :logger
|
|
29
|
+
|
|
30
|
+
def initialize
|
|
31
|
+
@default_description_source = :iso
|
|
32
|
+
@include_reserved_ranges = false
|
|
33
|
+
@cache_enabled = true
|
|
34
|
+
@data_path = default_data_path
|
|
35
|
+
@logger = nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Returns the default path to the data directory
|
|
39
|
+
#
|
|
40
|
+
# @return [String] the default data path
|
|
41
|
+
def default_data_path
|
|
42
|
+
File.expand_path("data", __dir__)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Validates the current configuration
|
|
46
|
+
#
|
|
47
|
+
# @raise [ConfigurationError] if the configuration is invalid
|
|
48
|
+
# @return [true] if the configuration is valid
|
|
49
|
+
def validate!
|
|
50
|
+
raise ConfigurationError, "data_path cannot be blank" if data_path.to_s.strip.empty?
|
|
51
|
+
|
|
52
|
+
unless DESCRIPTION_SOURCES.include?(default_description_source)
|
|
53
|
+
raise ConfigurationError,
|
|
54
|
+
"default_description_source must be one of: #{DESCRIPTION_SOURCES.join(", ")}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Resets the configuration to defaults
|
|
61
|
+
#
|
|
62
|
+
# @return [self]
|
|
63
|
+
def reset!
|
|
64
|
+
@default_description_source = :iso
|
|
65
|
+
@include_reserved_ranges = false
|
|
66
|
+
@cache_enabled = true
|
|
67
|
+
@data_path = default_data_path
|
|
68
|
+
@logger = nil
|
|
69
|
+
self
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Returns a hash representation of the configuration
|
|
73
|
+
#
|
|
74
|
+
# @return [Hash] the configuration as a hash
|
|
75
|
+
def to_h
|
|
76
|
+
{
|
|
77
|
+
default_description_source: default_description_source,
|
|
78
|
+
include_reserved_ranges: include_reserved_ranges,
|
|
79
|
+
cache_enabled: cache_enabled,
|
|
80
|
+
data_path: data_path,
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
File without changes
|