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.
@@ -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