ee_e_business_register 0.3.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,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EeEBusinessRegister
4
+ module Services
5
+ class TrustsService
6
+ def initialize(client = Client.new)
7
+ @client = client
8
+ @credentials = EeEBusinessRegister.configuration.credentials
9
+ end
10
+
11
+ def search_trusts(trust_name: nil, person_first_name: nil, person_last_name: nil,
12
+ person_birth_date: nil, person_id_code: nil, only_valid: false,
13
+ page: 1, per_page: 10)
14
+
15
+ params = build_trusts_params(
16
+ trust_name: trust_name,
17
+ person_first_name: person_first_name,
18
+ person_last_name: person_last_name,
19
+ person_birth_date: person_birth_date,
20
+ person_id_code: person_id_code,
21
+ only_valid: only_valid,
22
+ page: page,
23
+ per_page: per_page
24
+ )
25
+
26
+ response = @client.call(:usaldushaldused_v1, params)
27
+ parse_trusts_response(response)
28
+ end
29
+
30
+ def get_trust_by_name(trust_name, only_valid: true)
31
+ search_trusts(trust_name: trust_name, only_valid: only_valid)
32
+ end
33
+
34
+ def get_trusts_by_person(person_id_code: nil, person_first_name: nil,
35
+ person_last_name: nil, person_birth_date: nil,
36
+ only_valid: true)
37
+ search_trusts(
38
+ person_id_code: person_id_code,
39
+ person_first_name: person_first_name,
40
+ person_last_name: person_last_name,
41
+ person_birth_date: person_birth_date,
42
+ only_valid: only_valid
43
+ )
44
+ end
45
+
46
+ private
47
+
48
+ def build_trusts_params(trust_name: nil, person_first_name: nil, person_last_name: nil,
49
+ person_birth_date: nil, person_id_code: nil, only_valid: false,
50
+ page: 1, per_page: 10)
51
+ {
52
+ ariregister_kasutajanimi: @credentials[:username],
53
+ ariregister_parool: @credentials[:password],
54
+ ariregister_sessioon: @credentials[:session],
55
+ ariregister_valjundi_formaat: 'xml',
56
+ usaldushalduse_nimi: trust_name,
57
+ fyysilise_isiku_eesnimi: person_first_name,
58
+ fyysilise_isiku_perekonnanimi: person_last_name,
59
+ fyysilise_isiku_synniaeg: person_birth_date,
60
+ fyysilise_isiku_kood: person_id_code,
61
+ ainult_kehtivad: only_valid,
62
+ keel: EeEBusinessRegister.configuration.language,
63
+ evarv: per_page,
64
+ lehekylg: page
65
+ }.compact
66
+ end
67
+
68
+ def parse_trusts_response(response)
69
+ return nil unless response && response[:keha]
70
+
71
+ body = response[:keha]
72
+
73
+ Models::Trusts.new(
74
+ items: parse_trust_items(body[:usaldushaldused]),
75
+ total_count: body[:leitud_arv],
76
+ page: response.dig(:paring, :lehekylg) || 1,
77
+ per_page: response.dig(:paring, :evarv) || 10
78
+ )
79
+ end
80
+
81
+ def parse_trust_items(trusts_data)
82
+ return [] unless trusts_data && trusts_data[:item]
83
+
84
+ items = trusts_data[:item]
85
+ items = [items] unless items.is_a?(Array)
86
+
87
+ items.map { |trust| build_trust(trust) }
88
+ end
89
+
90
+ def build_trust(trust_data)
91
+ Models::Trust.new(
92
+ trust_id: trust_data[:usaldushalduse_id],
93
+ name: trust_data[:nimi],
94
+ registration_date: trust_data[:registreerimise_kpv],
95
+ status: trust_data[:staatus],
96
+ country: trust_data[:riik],
97
+ country_text: trust_data[:riik_tekstina],
98
+ total_beneficial_owners: trust_data[:kasusaajate_arv_kokku],
99
+ hidden_beneficial_owners: trust_data[:peidetud_kasusaajate_arv],
100
+ absence_notice: trust_data[:lahknevusteade_puudumisest],
101
+ persons: build_trust_persons(trust_data[:isikud])
102
+ )
103
+ end
104
+
105
+ def build_trust_persons(persons_data)
106
+ return [] unless persons_data && persons_data[:isik]
107
+
108
+ persons = persons_data[:isik]
109
+ persons = [persons] unless persons.is_a?(Array)
110
+
111
+ persons.map { |person| build_trust_person(person) }
112
+ end
113
+
114
+ def build_trust_person(person_data)
115
+ Models::TrustPerson.new(
116
+ role: person_data[:roll],
117
+ first_name: person_data[:eesnimi],
118
+ last_name: person_data[:nimi],
119
+ company_name: (!person_data[:eesnimi] && person_data[:nimi]) ? person_data[:nimi] : nil,
120
+ id_code: person_data[:isikukood],
121
+ foreign_id_code: person_data[:valis_kood],
122
+ foreign_id_country: person_data[:valis_kood_riik],
123
+ foreign_id_country_text: person_data[:valis_kood_riik_tekstina],
124
+ birth_date: person_data[:synniaeg],
125
+ address_country: person_data[:aadress_riik],
126
+ address_country_text: person_data[:aadress_riik_tesktina] || person_data[:aadress_riik_tekstina],
127
+ residence_country: person_data[:elukoht_riik],
128
+ residence_country_text: person_data[:elukoht_riik_tekstina],
129
+ start_date: person_data[:algus_kpv],
130
+ end_date: person_data[:lopp_kpv],
131
+ discrepancy_notice_submitted: person_data[:lahknevusteade_esitatud]
132
+ )
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-types"
4
+
5
+ module EeEBusinessRegister
6
+ module Types
7
+ include Dry.Types()
8
+
9
+ RegistryCode = Strict::String.constrained(format: /\A\d{8}\z/)
10
+
11
+ CompanyStatus = Strict::String.enum(
12
+ "R", # Registered
13
+ "K", # Deleted
14
+ "L", # Liquidated
15
+ "N", # Active in liquidation
16
+ "S" # Active in reorganization
17
+ )
18
+
19
+ LegalForm = Strict::String
20
+ Language = Strict::String.enum("est", "eng")
21
+
22
+ Date = Strict::String.constrained(format: /\A\d{4}-\d{2}-\d{2}\z/) | Strict::String.constrained(format: /\A\d{2}\.\d{2}\.\d{4}\z/)
23
+ end
24
+ end
@@ -0,0 +1,367 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EeEBusinessRegister
4
+ # Comprehensive input validation and sanitization module
5
+ #
6
+ # This module provides robust validation and sanitization for all user inputs
7
+ # that interact with the Estonian e-Business Register API. It serves multiple
8
+ # critical security and data integrity purposes:
9
+ #
10
+ # **Security Functions:**
11
+ # - Prevents XML/SOAP injection attacks by sanitizing malicious input
12
+ # - Blocks potential XSS and code injection attempts
13
+ # - Validates input formats to prevent API abuse and DoS attacks
14
+ #
15
+ # **Data Quality Functions:**
16
+ # - Ensures Estonian registry codes follow the correct 8-digit format
17
+ # - Validates Estonian personal identification codes with checksum verification
18
+ # - Standardizes date formats across different input types
19
+ # - Normalizes company and person names for consistent searching
20
+ #
21
+ # **API Protection:**
22
+ # - Implements rate limiting through pagination and result count validation
23
+ # - Prevents excessive time range queries that could overload the Estonian API
24
+ # - Enforces reasonable limits on bulk operations
25
+ #
26
+ # All validation methods follow a consistent pattern: they either return
27
+ # sanitized/validated data or raise ValidationError for invalid inputs.
28
+ # Methods that handle potentially dangerous content (like names for search)
29
+ # include XSS prevention and character filtering.
30
+ #
31
+ # @example Basic usage
32
+ # registry_code = Validation.validate_registry_code("16863232")
33
+ # company_name = Validation.validate_company_name("Sorbeet Payments OÜ")
34
+ # date_range = Validation.validate_time_interval("2023-01-01", "2023-01-31")
35
+ #
36
+ module Validation
37
+ # Exception raised when input validation fails
38
+ #
39
+ # This error is thrown when user input doesn't meet the required format,
40
+ # contains potentially dangerous content, or exceeds allowed limits.
41
+ # The error message provides specific guidance on what was wrong and
42
+ # how to fix it.
43
+ #
44
+ class ValidationError < StandardError; end
45
+
46
+ # Regular expression for validating Estonian company registry codes
47
+ # Estonian companies have unique 8-digit identification codes
48
+ REGISTRY_CODE_PATTERN = /\A\d{8}\z/.freeze
49
+
50
+ # Regular expression for validating Estonian personal identification codes
51
+ # Format: First digit (1-6) indicates century and gender, followed by 10 more digits
52
+ # 1,2 = 1800-1899, 3,4 = 1900-1999, 5,6 = 2000-2099
53
+ # Odd numbers = male, even numbers = female
54
+ PERSONAL_CODE_PATTERN = /\A[1-6]\d{10}\z/.freeze
55
+
56
+ # Supported date format patterns for input validation
57
+ # Covers standard ISO formats and Estonian DD.MM.YYYY format
58
+ DATE_PATTERNS = [
59
+ /\A\d{4}-\d{2}-\d{2}\z/, # YYYY-MM-DD (ISO date)
60
+ /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z?\z/, # ISO 8601 datetime with optional Z
61
+ /\A\d{2}\.\d{2}\.\d{4}\z/ # DD.MM.YYYY (Estonian format)
62
+ ].freeze
63
+
64
+ # Supported language codes for API responses
65
+ # 'est' = Estonian, 'eng' = English
66
+ VALID_LANGUAGES = %w[est eng].freeze
67
+
68
+ # Common Estonian company legal form abbreviations
69
+ # OÜ = Osaühing (LLC), AS = Aktsiaselts (JSC), MTÜ = Mittetulundusühing (NPO), etc.
70
+ VALID_LEGAL_FORMS = %w[OÜ AS MTÜ SA TÜH UÜ].freeze
71
+
72
+ class << self
73
+ # Validate and sanitize registry code
74
+ # @param code [String, Integer] Registry code to validate
75
+ # @return [String] Sanitized 8-digit registry code
76
+ # @raise [ValidationError] If code format is invalid
77
+ def validate_registry_code(code)
78
+ return nil if code.nil? || code.to_s.strip.empty?
79
+
80
+ # Remove all non-numeric characters
81
+ sanitized = code.to_s.strip.gsub(/[^\d]/, '')
82
+
83
+ unless sanitized.match?(REGISTRY_CODE_PATTERN)
84
+ raise ValidationError,
85
+ "Invalid registry code format '#{code}'. Must be exactly 8 digits."
86
+ end
87
+
88
+ sanitized
89
+ end
90
+
91
+ # Validate Estonian personal identification code
92
+ # @param code [String] Personal code to validate
93
+ # @return [String, nil] Sanitized personal code or nil if invalid
94
+ def validate_personal_code(code)
95
+ return nil if code.nil? || code.to_s.strip.empty?
96
+
97
+ sanitized = code.to_s.strip.gsub(/[^\d]/, '')
98
+
99
+ return nil unless sanitized.match?(PERSONAL_CODE_PATTERN)
100
+
101
+ # Validate checksum for Estonian personal codes
102
+ return nil unless valid_personal_code_checksum?(sanitized)
103
+
104
+ sanitized
105
+ end
106
+
107
+ # Validate and sanitize company name for search
108
+ # @param name [String] Company name to validate
109
+ # @return [String, nil] Sanitized company name
110
+ def validate_company_name(name)
111
+ return nil if name.nil?
112
+
113
+ sanitized = name.to_s.strip
114
+ return nil if sanitized.empty?
115
+
116
+ # Remove potentially dangerous characters but keep Unicode letters
117
+ # Allow letters, numbers, spaces, and common business punctuation
118
+ sanitized = sanitized.gsub(/[<>"';&|`$(){}\[\]\\]/, '')
119
+
120
+ # Limit length to prevent DoS
121
+ sanitized = sanitized[0, 255] if sanitized.length > 255
122
+
123
+ sanitized.empty? ? nil : sanitized
124
+ end
125
+
126
+ # Validate person name (first/last name)
127
+ # @param name [String] Person name to validate
128
+ # @return [String, nil] Sanitized person name
129
+ def validate_person_name(name)
130
+ return nil if name.nil?
131
+
132
+ sanitized = name.to_s.strip
133
+ return nil if sanitized.empty?
134
+
135
+ # Allow only letters, spaces, hyphens, and apostrophes
136
+ sanitized = sanitized.gsub(/[^a-zA-ZÀ-ÿĀ-žА-я\s\-']/, '')
137
+
138
+ # Limit length
139
+ sanitized = sanitized[0, 100] if sanitized.length > 100
140
+
141
+ sanitized.empty? ? nil : sanitized
142
+ end
143
+
144
+ # Validate date input
145
+ # @param date [String, Date, Time] Date to validate
146
+ # @return [String, nil] ISO formatted date string
147
+ def validate_date(date)
148
+ return nil if date.nil?
149
+
150
+ case date
151
+ when Date
152
+ date.iso8601
153
+ when Time
154
+ date.to_date.iso8601
155
+ when String
156
+ sanitized = date.to_s.strip
157
+ return nil if sanitized.empty?
158
+
159
+ # Check if it matches expected patterns
160
+ return sanitized if DATE_PATTERNS.any? { |pattern| sanitized.match?(pattern) }
161
+
162
+ # Try to parse as date
163
+ begin
164
+ parsed_date = Date.parse(sanitized)
165
+ parsed_date.iso8601
166
+ rescue ArgumentError
167
+ raise ValidationError, "Invalid date format '#{date}'. Use YYYY-MM-DD format."
168
+ end
169
+ else
170
+ raise ValidationError, "Date must be a String, Date, or Time object"
171
+ end
172
+ end
173
+
174
+ # Validate language code
175
+ # @param language [String] Language code to validate
176
+ # @return [String] Valid language code
177
+ # @raise [ValidationError] If language is not supported
178
+ def validate_language(language)
179
+ return 'eng' if language.nil? || language.to_s.strip.empty?
180
+
181
+ lang = language.to_s.strip.downcase
182
+
183
+ unless VALID_LANGUAGES.include?(lang)
184
+ raise ValidationError,
185
+ "Invalid language '#{language}'. Must be one of: #{VALID_LANGUAGES.join(', ')}"
186
+ end
187
+
188
+ lang
189
+ end
190
+
191
+ # Validate page number for pagination
192
+ # @param page [Integer, String] Page number
193
+ # @return [Integer] Valid page number (minimum 1)
194
+ def validate_page_number(page)
195
+ return 1 if page.nil?
196
+
197
+ page_num = page.to_i
198
+
199
+ if page_num < 1
200
+ raise ValidationError, "Page number must be 1 or greater, got #{page}"
201
+ end
202
+
203
+ # Limit maximum page to prevent resource exhaustion
204
+ if page_num > 10000
205
+ raise ValidationError, "Page number too large (max 10000), got #{page}"
206
+ end
207
+
208
+ page_num
209
+ end
210
+
211
+ # Validate results per page limit
212
+ # @param limit [Integer, String] Number of results per page
213
+ # @param max_allowed [Integer] Maximum allowed limit
214
+ # @return [Integer] Valid limit
215
+ def validate_results_limit(limit, max_allowed = 100)
216
+ return 10 if limit.nil? # Default limit
217
+
218
+ limit_num = limit.to_i
219
+
220
+ if limit_num < 1
221
+ raise ValidationError, "Results limit must be 1 or greater, got #{limit}"
222
+ end
223
+
224
+ if limit_num > max_allowed
225
+ raise ValidationError,
226
+ "Results limit too large (max #{max_allowed}), got #{limit}"
227
+ end
228
+
229
+ limit_num
230
+ end
231
+
232
+ # Validate legal form codes
233
+ # @param forms [Array<String>] Legal form codes
234
+ # @return [Array<String>] Valid legal form codes
235
+ def validate_legal_forms(forms)
236
+ return [] if forms.nil? || forms.empty?
237
+
238
+ forms = [forms] unless forms.is_a?(Array)
239
+
240
+ validated = forms.map do |form|
241
+ sanitized = form.to_s.strip.upcase
242
+ next nil if sanitized.empty?
243
+
244
+ # Allow both official codes and common abbreviations
245
+ sanitized
246
+ end.compact
247
+
248
+ validated
249
+ end
250
+
251
+ # Sanitize XML input to prevent injection attacks
252
+ # @param input [String] Raw input that will be used in XML/SOAP
253
+ # @return [String] Sanitized input safe for XML
254
+ def sanitize_xml_input(input)
255
+ return '' if input.nil?
256
+
257
+ input.to_s
258
+ .gsub(/[<>]/, '') # Remove XML brackets
259
+ .gsub(/[&]/, '&amp;') # Escape ampersands
260
+ .gsub(/["']/, '') # Remove quotes
261
+ .strip
262
+ end
263
+
264
+ # Validate array of registry codes (for bulk operations)
265
+ # @param codes [Array] Array of registry codes
266
+ # @param max_count [Integer] Maximum number of codes allowed
267
+ # @return [Array<String>] Array of validated registry codes
268
+ def validate_registry_codes_array(codes, max_count = 100)
269
+ if codes.nil? || codes.empty?
270
+ raise ValidationError, "Registry codes array cannot be empty"
271
+ end
272
+
273
+ codes = [codes] unless codes.is_a?(Array)
274
+
275
+ if codes.length > max_count
276
+ raise ValidationError,
277
+ "Too many registry codes (max #{max_count}), got #{codes.length}"
278
+ end
279
+
280
+ codes.map { |code| validate_registry_code(code) }.compact
281
+ end
282
+
283
+ # Validate time interval to prevent excessive API load
284
+ # @param start_time [Time, String] Start of time interval
285
+ # @param end_time [Time, String] End of time interval
286
+ # @param max_days [Integer] Maximum allowed interval in days
287
+ # @return [Array<Time>] Array of [start_time, end_time]
288
+ def validate_time_interval(start_time, end_time, max_days = 7)
289
+ start_t = parse_time_input(start_time)
290
+ end_t = parse_time_input(end_time)
291
+
292
+ unless start_t && end_t
293
+ raise ValidationError,
294
+ "Invalid time format. Use ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ)"
295
+ end
296
+
297
+ if start_t >= end_t
298
+ raise ValidationError, "Start time must be before end time"
299
+ end
300
+
301
+ interval_days = (end_t - start_t) / 86400.0 # Convert seconds to days
302
+
303
+ if interval_days > max_days
304
+ raise ValidationError,
305
+ "Time interval too large (max #{max_days} days), got #{interval_days.round(1)} days"
306
+ end
307
+
308
+ # Prevent queries too far in the past (data availability)
309
+ max_history_days = 3650 # ~10 years
310
+ days_ago = (Time.now - start_t) / 86400.0
311
+
312
+ if days_ago > max_history_days
313
+ raise ValidationError,
314
+ "Start time too far in the past (max #{max_history_days} days ago)"
315
+ end
316
+
317
+ [start_t, end_t]
318
+ end
319
+
320
+ private
321
+
322
+ # Parse time input from various formats
323
+ # @param time_input [Time, String, Integer] Time in various formats
324
+ # @return [Time, nil] Parsed time or nil if invalid
325
+ def parse_time_input(time_input)
326
+ case time_input
327
+ when Time
328
+ time_input
329
+ when String
330
+ Time.parse(time_input)
331
+ when Integer
332
+ Time.at(time_input) # Unix timestamp
333
+ else
334
+ nil
335
+ end
336
+ rescue ArgumentError
337
+ nil
338
+ end
339
+
340
+ # Validate Estonian personal code checksum
341
+ # @param code [String] 11-digit personal code
342
+ # @return [Boolean] True if checksum is valid
343
+ def valid_personal_code_checksum?(code)
344
+ return false unless code.length == 11
345
+
346
+ digits = code.chars.map(&:to_i)
347
+
348
+ # First checksum calculation
349
+ weights1 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 1]
350
+ sum1 = digits[0, 10].each_with_index.sum { |digit, i| digit * weights1[i] }
351
+ remainder1 = sum1 % 11
352
+
353
+ if remainder1 < 10
354
+ return digits[10] == remainder1
355
+ end
356
+
357
+ # Second checksum calculation if first gives 10
358
+ weights2 = [3, 4, 5, 6, 7, 8, 9, 1, 2, 3]
359
+ sum2 = digits[0, 10].each_with_index.sum { |digit, i| digit * weights2[i] }
360
+ remainder2 = sum2 % 11
361
+
362
+ checksum = remainder2 < 10 ? remainder2 : 0
363
+ digits[10] == checksum
364
+ end
365
+ end
366
+ end
367
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EeEBusinessRegister
4
+ VERSION = "0.3.0"
5
+ end