clover_sandbox_simulator 1.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 (25) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +10 -0
  3. data/README.md +316 -0
  4. data/bin/simulate +209 -0
  5. data/lib/clover_sandbox_simulator/configuration.rb +51 -0
  6. data/lib/clover_sandbox_simulator/data/restaurant/categories.json +39 -0
  7. data/lib/clover_sandbox_simulator/data/restaurant/discounts.json +39 -0
  8. data/lib/clover_sandbox_simulator/data/restaurant/items.json +238 -0
  9. data/lib/clover_sandbox_simulator/data/restaurant/modifiers.json +62 -0
  10. data/lib/clover_sandbox_simulator/data/restaurant/tenders.json +41 -0
  11. data/lib/clover_sandbox_simulator/generators/data_loader.rb +54 -0
  12. data/lib/clover_sandbox_simulator/generators/entity_generator.rb +164 -0
  13. data/lib/clover_sandbox_simulator/generators/order_generator.rb +540 -0
  14. data/lib/clover_sandbox_simulator/services/base_service.rb +111 -0
  15. data/lib/clover_sandbox_simulator/services/clover/customer_service.rb +82 -0
  16. data/lib/clover_sandbox_simulator/services/clover/discount_service.rb +58 -0
  17. data/lib/clover_sandbox_simulator/services/clover/employee_service.rb +82 -0
  18. data/lib/clover_sandbox_simulator/services/clover/inventory_service.rb +120 -0
  19. data/lib/clover_sandbox_simulator/services/clover/order_service.rb +170 -0
  20. data/lib/clover_sandbox_simulator/services/clover/payment_service.rb +123 -0
  21. data/lib/clover_sandbox_simulator/services/clover/services_manager.rb +49 -0
  22. data/lib/clover_sandbox_simulator/services/clover/tax_service.rb +53 -0
  23. data/lib/clover_sandbox_simulator/services/clover/tender_service.rb +117 -0
  24. data/lib/clover_sandbox_simulator.rb +43 -0
  25. metadata +195 -0
@@ -0,0 +1,540 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CloverSandboxSimulator
4
+ module Generators
5
+ # Generates realistic restaurant orders and payments
6
+ class OrderGenerator
7
+ # Meal periods with realistic distributions
8
+ MEAL_PERIODS = {
9
+ breakfast: { hours: 7..10, weight: 15, avg_items: 2..4, avg_party: 1..2 },
10
+ lunch: { hours: 11..14, weight: 30, avg_items: 2..5, avg_party: 1..4 },
11
+ happy_hour: { hours: 15..17, weight: 10, avg_items: 2..4, avg_party: 2..4 },
12
+ dinner: { hours: 17..21, weight: 35, avg_items: 3..6, avg_party: 2..6 },
13
+ late_night: { hours: 21..23, weight: 10, avg_items: 2..4, avg_party: 1..3 }
14
+ }.freeze
15
+
16
+ # Dining option distributions by meal period
17
+ DINING_BY_PERIOD = {
18
+ breakfast: { "HERE" => 40, "TO_GO" => 50, "DELIVERY" => 10 },
19
+ lunch: { "HERE" => 35, "TO_GO" => 45, "DELIVERY" => 20 },
20
+ happy_hour: { "HERE" => 80, "TO_GO" => 15, "DELIVERY" => 5 },
21
+ dinner: { "HERE" => 70, "TO_GO" => 15, "DELIVERY" => 15 },
22
+ late_night: { "HERE" => 50, "TO_GO" => 30, "DELIVERY" => 20 }
23
+ }.freeze
24
+
25
+ # Tip percentages by dining option
26
+ TIP_RATES = {
27
+ "HERE" => { min: 15, max: 25 }, # Dine-in tips higher
28
+ "TO_GO" => { min: 0, max: 15 }, # Takeout tips lower
29
+ "DELIVERY" => { min: 10, max: 20 } # Delivery tips moderate
30
+ }.freeze
31
+
32
+ # Order patterns by day of week
33
+ ORDER_PATTERNS = {
34
+ weekday: { min: 40, max: 60 },
35
+ friday: { min: 70, max: 100 },
36
+ saturday: { min: 80, max: 120 },
37
+ sunday: { min: 50, max: 80 }
38
+ }.freeze
39
+
40
+ # Category preferences by meal period
41
+ CATEGORY_PREFERENCES = {
42
+ breakfast: ["Drinks", "Sides"],
43
+ lunch: ["Appetizers", "Entrees", "Sides", "Drinks"],
44
+ happy_hour: ["Appetizers", "Alcoholic Beverages", "Drinks"],
45
+ dinner: ["Appetizers", "Entrees", "Sides", "Desserts", "Alcoholic Beverages", "Drinks"],
46
+ late_night: ["Appetizers", "Entrees", "Alcoholic Beverages", "Desserts"]
47
+ }.freeze
48
+
49
+ attr_reader :services, :logger, :stats
50
+
51
+ def initialize(services: nil)
52
+ @services = services || Services::Clover::ServicesManager.new
53
+ @logger = CloverSandboxSimulator.logger
54
+ @stats = { orders: 0, revenue: 0, tips: 0, tax: 0, by_period: {}, by_dining: {} }
55
+ end
56
+
57
+ # Generate a realistic day of restaurant operations
58
+ def generate_realistic_day(date: Date.today, multiplier: 1.0)
59
+ count = (order_count_for_date(date) * multiplier).to_i
60
+
61
+ logger.info "=" * 60
62
+ logger.info "🍽️ Generating realistic restaurant day: #{date}"
63
+ logger.info " Target orders: #{count}"
64
+ logger.info " Day: #{date.strftime('%A')}"
65
+ logger.info "=" * 60
66
+
67
+ # Fetch required data
68
+ data = fetch_required_data
69
+ return [] unless data
70
+
71
+ # Distribute orders across meal periods
72
+ period_orders = distribute_orders_by_period(count)
73
+
74
+ orders = []
75
+ period_orders.each do |period, period_count|
76
+ logger.info "-" * 40
77
+ logger.info "📍 #{period.to_s.upcase} SERVICE: #{period_count} orders"
78
+
79
+ period_count.times do |i|
80
+ order = create_realistic_order(
81
+ period: period,
82
+ data: data,
83
+ order_num: i + 1,
84
+ total_in_period: period_count
85
+ )
86
+
87
+ if order
88
+ orders << order
89
+ update_stats(order, period)
90
+ end
91
+ end
92
+ end
93
+
94
+ print_summary
95
+ orders
96
+ end
97
+
98
+ # Generate orders for today (simple mode)
99
+ def generate_today(count: nil)
100
+ if count
101
+ generate_for_date(Date.today, count: count)
102
+ else
103
+ generate_realistic_day
104
+ end
105
+ end
106
+
107
+ # Generate specific count of orders
108
+ def generate_for_date(date, count:)
109
+ logger.info "=" * 60
110
+ logger.info "Generating #{count} orders for #{date}"
111
+ logger.info "=" * 60
112
+
113
+ data = fetch_required_data
114
+ return [] unless data
115
+
116
+ orders = []
117
+ count.times do |i|
118
+ period = weighted_random_period
119
+ logger.info "-" * 40
120
+ logger.info "Creating order #{i + 1}/#{count} (#{period})"
121
+
122
+ order = create_realistic_order(
123
+ period: period,
124
+ data: data,
125
+ order_num: i + 1,
126
+ total_in_period: count
127
+ )
128
+
129
+ if order
130
+ orders << order
131
+ update_stats(order, period)
132
+ end
133
+ end
134
+
135
+ print_summary
136
+ orders
137
+ end
138
+
139
+ private
140
+
141
+ def fetch_required_data
142
+ items = services.inventory.get_items
143
+ employees = services.employee.get_employees
144
+ customers = services.customer.get_customers
145
+ tenders = services.tender.get_safe_tenders
146
+ discounts = services.discount.get_discounts
147
+
148
+ if items.empty?
149
+ logger.error "No items found! Please run setup first."
150
+ return nil
151
+ end
152
+
153
+ if employees.empty?
154
+ logger.error "No employees found! Please run setup first."
155
+ return nil
156
+ end
157
+
158
+ if tenders.empty?
159
+ logger.error "No safe tenders found!"
160
+ return nil
161
+ end
162
+
163
+ # Categorize items for meal period selection
164
+ items_by_category = items.group_by { |i| i.dig("categories", "elements", 0, "name") || "Other" }
165
+
166
+ {
167
+ items: items,
168
+ items_by_category: items_by_category,
169
+ employees: employees,
170
+ customers: customers,
171
+ tenders: tenders,
172
+ discounts: discounts
173
+ }
174
+ end
175
+
176
+ def distribute_orders_by_period(total_count)
177
+ total_weight = MEAL_PERIODS.values.sum { |p| p[:weight] }
178
+
179
+ distribution = {}
180
+ remaining = total_count
181
+
182
+ MEAL_PERIODS.each_with_index do |(period, config), index|
183
+ if index == MEAL_PERIODS.size - 1
184
+ distribution[period] = remaining
185
+ else
186
+ count = ((config[:weight].to_f / total_weight) * total_count).round
187
+ distribution[period] = [count, remaining].min
188
+ remaining -= distribution[period]
189
+ end
190
+ end
191
+
192
+ distribution
193
+ end
194
+
195
+ def weighted_random_period
196
+ total_weight = MEAL_PERIODS.values.sum { |p| p[:weight] }
197
+ random = rand(total_weight)
198
+
199
+ cumulative = 0
200
+ MEAL_PERIODS.each do |period, config|
201
+ cumulative += config[:weight]
202
+ return period if random < cumulative
203
+ end
204
+
205
+ :dinner # fallback
206
+ end
207
+
208
+ def create_realistic_order(period:, data:, order_num:, total_in_period:)
209
+ config = MEAL_PERIODS[period]
210
+ employee = data[:employees].sample
211
+
212
+ # 60% of orders have customer info (regulars, rewards members)
213
+ customer = data[:customers].sample if rand < 0.6
214
+
215
+ # Create order shell
216
+ order = services.order.create_order(
217
+ employee_id: employee["id"],
218
+ customer_id: customer&.dig("id")
219
+ )
220
+
221
+ return nil unless order && order["id"]
222
+
223
+ order_id = order["id"]
224
+ logger.info "Created order: #{order_id}"
225
+
226
+ # Set dining option based on meal period
227
+ dining = select_dining_option(period)
228
+ services.order.set_dining_option(order_id, dining)
229
+ logger.debug " Dining: #{dining}"
230
+
231
+ # Party size affects item count
232
+ party_size = rand(config[:avg_party])
233
+ base_items = rand(config[:avg_items])
234
+ num_items = [base_items + (party_size / 2), 1].max
235
+
236
+ # Select items appropriate for the meal period
237
+ selected_items = select_items_for_period(period, data, num_items, party_size)
238
+
239
+ line_items = selected_items.map do |item|
240
+ # Quantity varies by party size
241
+ quantity = party_size > 2 && rand < 0.3 ? rand(2..3) : 1
242
+ note = random_note if rand < 0.15
243
+
244
+ {
245
+ item_id: item["id"],
246
+ quantity: quantity,
247
+ note: note
248
+ }
249
+ end
250
+
251
+ services.order.add_line_items(order_id, line_items)
252
+
253
+ # Apply discount (15% chance, higher for regulars)
254
+ discount_chance = customer ? 0.20 : 0.10
255
+ if rand < discount_chance && data[:discounts].any?
256
+ discount = select_appropriate_discount(data[:discounts], period)
257
+ services.order.apply_discount(order_id, discount_id: discount["id"]) if discount
258
+ end
259
+
260
+ # Calculate totals
261
+ subtotal = services.order.calculate_total(order_id)
262
+ services.order.update_total(order_id, subtotal)
263
+
264
+ # Calculate tax and tip (tip varies by dining option)
265
+ tax_amount = services.tax.calculate_tax(subtotal)
266
+ tip_amount = calculate_tip(subtotal, dining, party_size)
267
+
268
+ # Process payment
269
+ process_order_payment(
270
+ order_id: order_id,
271
+ subtotal: subtotal,
272
+ tax_amount: tax_amount,
273
+ tip_amount: tip_amount,
274
+ employee_id: employee["id"],
275
+ tenders: data[:tenders],
276
+ dining: dining,
277
+ party_size: party_size
278
+ )
279
+
280
+ # Update order state to paid
281
+ services.order.update_state(order_id, "paid")
282
+
283
+ # Return final order with metadata
284
+ final_order = services.order.get_order(order_id)
285
+ final_order["_metadata"] = {
286
+ period: period,
287
+ dining: dining,
288
+ party_size: party_size,
289
+ tip: tip_amount,
290
+ tax: tax_amount
291
+ }
292
+
293
+ final_order
294
+ end
295
+
296
+ def select_dining_option(period)
297
+ distribution = DINING_BY_PERIOD[period]
298
+ random = rand(100)
299
+
300
+ cumulative = 0
301
+ distribution.each do |option, weight|
302
+ cumulative += weight
303
+ return option if random < cumulative
304
+ end
305
+
306
+ "HERE"
307
+ end
308
+
309
+ def select_items_for_period(period, data, count, party_size)
310
+ preferred_categories = CATEGORY_PREFERENCES[period] || CATEGORY_PREFERENCES[:dinner]
311
+
312
+ # Build weighted item pool
313
+ weighted_items = []
314
+
315
+ preferred_categories.each do |category|
316
+ items = data[:items_by_category][category] || []
317
+ # Add preferred items with higher weight
318
+ items.each { |item| weighted_items.concat([item] * 3) }
319
+ end
320
+
321
+ # Add all items with lower weight for variety
322
+ data[:items].each { |item| weighted_items << item }
323
+
324
+ # For larger parties, ensure variety
325
+ if party_size >= 4
326
+ # Try to get items from different categories
327
+ selected = []
328
+ preferred_categories.each do |category|
329
+ items = data[:items_by_category][category] || []
330
+ selected << items.sample if items.any? && selected.size < count
331
+ end
332
+
333
+ # Fill remaining with weighted random (with safeguard against infinite loop)
334
+ unique_items = weighted_items.uniq
335
+ max_attempts = unique_items.size * 2
336
+ attempts = 0
337
+ while selected.size < count && attempts < max_attempts
338
+ item = weighted_items.sample
339
+ selected << item unless selected.include?(item)
340
+ attempts += 1
341
+ end
342
+
343
+ selected.take(count)
344
+ else
345
+ weighted_items.sample(count).uniq.take(count)
346
+ end
347
+ end
348
+
349
+ def select_appropriate_discount(discounts, period)
350
+ # Happy hour discounts more likely during happy hour
351
+ if period == :happy_hour
352
+ happy_discount = discounts.find { |d| d["name"]&.downcase&.include?("happy") }
353
+ return happy_discount if happy_discount && rand < 0.5
354
+ end
355
+
356
+ discounts.sample
357
+ end
358
+
359
+ def calculate_tip(subtotal, dining, party_size)
360
+ rates = TIP_RATES[dining] || TIP_RATES["HERE"]
361
+
362
+ # Base tip percentage
363
+ tip_percent = rand(rates[:min]..rates[:max])
364
+
365
+ # Larger parties sometimes tip less per person but more total
366
+ if party_size >= 6
367
+ tip_percent = [tip_percent, 18].max # Auto-grat for large parties
368
+ end
369
+
370
+ # Some people don't tip on takeout
371
+ if dining == "TO_GO" && rand < 0.3
372
+ tip_percent = 0
373
+ end
374
+
375
+ (subtotal * tip_percent / 100.0).round
376
+ end
377
+
378
+ def process_order_payment(order_id:, subtotal:, tax_amount:, tip_amount:, employee_id:, tenders:, dining:, party_size:)
379
+ # Ensure party_size is a valid number
380
+ party_size = party_size.to_i
381
+ party_size = 1 if party_size < 1
382
+
383
+ # Split payment more likely for larger parties dining in
384
+ split_chance = dining == "HERE" && party_size >= 2 ? 0.25 : 0.05
385
+
386
+ if rand < split_chance && tenders.size > 1
387
+ num_splits = [party_size, 4, tenders.size].min
388
+ num_splits = num_splits < 2 ? 2 : rand(2..num_splits)
389
+ splits = select_split_tenders(tenders, num_splits)
390
+
391
+ logger.debug " Split payment: #{num_splits} ways"
392
+
393
+ services.payment.process_split_payment(
394
+ order_id: order_id,
395
+ total_amount: subtotal,
396
+ tip_amount: tip_amount,
397
+ tax_amount: tax_amount,
398
+ employee_id: employee_id,
399
+ splits: splits
400
+ )
401
+ else
402
+ # Cash more common for smaller orders
403
+ tender = if subtotal < 2000 && rand < 0.4
404
+ tenders.find { |t| t["label"]&.downcase == "cash" } || tenders.sample
405
+ else
406
+ tenders.sample
407
+ end
408
+
409
+ services.payment.process_payment(
410
+ order_id: order_id,
411
+ amount: subtotal,
412
+ tender_id: tender["id"],
413
+ employee_id: employee_id,
414
+ tip_amount: tip_amount,
415
+ tax_amount: tax_amount
416
+ )
417
+ end
418
+ end
419
+
420
+ def select_split_tenders(tenders, count)
421
+ return [] if tenders.nil? || tenders.empty? || count.nil? || count < 1
422
+
423
+ actual_count = [count.to_i, tenders.size].min
424
+ selected = tenders.sample(actual_count)
425
+ percentages = generate_split_percentages(selected.size)
426
+
427
+ selected.zip(percentages).map do |tender, pct|
428
+ { tender: tender, percentage: pct }
429
+ end
430
+ end
431
+
432
+ def generate_split_percentages(count)
433
+ return [100] if count == 1
434
+
435
+ # More realistic even splits
436
+ if rand < 0.7
437
+ # Even split
438
+ base = 100 / count
439
+ remainder = 100 % count
440
+ percentages = Array.new(count, base)
441
+ percentages[0] += remainder
442
+ percentages
443
+ else
444
+ # Random split
445
+ points = Array.new(count - 1) { rand(20..80) }.sort
446
+
447
+ percentages = []
448
+ prev = 0
449
+ points.each do |point|
450
+ percentages << (point - prev)
451
+ prev = point
452
+ end
453
+ percentages << (100 - prev)
454
+
455
+ percentages
456
+ end
457
+ end
458
+
459
+ def order_count_for_date(date)
460
+ pattern = case date.wday
461
+ when 0 then ORDER_PATTERNS[:sunday]
462
+ when 5 then ORDER_PATTERNS[:friday]
463
+ when 6 then ORDER_PATTERNS[:saturday]
464
+ else ORDER_PATTERNS[:weekday]
465
+ end
466
+
467
+ rand(pattern[:min]..pattern[:max])
468
+ end
469
+
470
+ def update_stats(order, period)
471
+ @stats[:orders] += 1
472
+
473
+ metadata = order["_metadata"] || {}
474
+ subtotal = order["total"] || 0
475
+ tip = metadata[:tip] || 0
476
+ tax = metadata[:tax] || 0
477
+ dining = metadata[:dining] || "HERE"
478
+
479
+ @stats[:revenue] += subtotal
480
+ @stats[:tips] += tip
481
+ @stats[:tax] += tax
482
+
483
+ @stats[:by_period][period] ||= { orders: 0, revenue: 0 }
484
+ @stats[:by_period][period][:orders] += 1
485
+ @stats[:by_period][period][:revenue] += subtotal
486
+
487
+ @stats[:by_dining][dining] ||= { orders: 0, revenue: 0 }
488
+ @stats[:by_dining][dining][:orders] += 1
489
+ @stats[:by_dining][dining][:revenue] += subtotal
490
+ end
491
+
492
+ def print_summary
493
+ logger.info ""
494
+ logger.info "=" * 60
495
+ logger.info "📊 DAILY SUMMARY"
496
+ logger.info "=" * 60
497
+ logger.info " Total Orders: #{@stats[:orders]}"
498
+ logger.info " Revenue: $#{'%.2f' % (@stats[:revenue] / 100.0)}"
499
+ logger.info " Tips: $#{'%.2f' % (@stats[:tips] / 100.0)}"
500
+ logger.info " Tax: $#{'%.2f' % (@stats[:tax] / 100.0)}"
501
+ logger.info " Grand Total: $#{'%.2f' % ((@stats[:revenue] + @stats[:tips] + @stats[:tax]) / 100.0)}"
502
+ logger.info ""
503
+ logger.info "📍 BY MEAL PERIOD:"
504
+ @stats[:by_period].each do |period, data|
505
+ avg = data[:orders] > 0 ? data[:revenue] / data[:orders] / 100.0 : 0
506
+ logger.info " #{period.to_s.ljust(12)} #{data[:orders].to_s.rjust(3)} orders | $#{'%.2f' % (data[:revenue] / 100.0)} | avg $#{'%.2f' % avg}"
507
+ end
508
+ logger.info ""
509
+ logger.info "🍽️ BY DINING OPTION:"
510
+ @stats[:by_dining].each do |dining, data|
511
+ logger.info " #{dining.ljust(12)} #{data[:orders].to_s.rjust(3)} orders | $#{'%.2f' % (data[:revenue] / 100.0)}"
512
+ end
513
+ logger.info "=" * 60
514
+ end
515
+
516
+ def random_note
517
+ notes = [
518
+ "No onions",
519
+ "Extra spicy",
520
+ "Gluten-free",
521
+ "Allergic to nuts",
522
+ "Light ice",
523
+ "No salt",
524
+ "Well done",
525
+ "Medium rare",
526
+ "Extra sauce on side",
527
+ "Dressing on side",
528
+ "No cheese",
529
+ "Add bacon",
530
+ "Birthday celebration",
531
+ "Anniversary dinner",
532
+ "VIP customer",
533
+ "Rush order",
534
+ "Separate checks"
535
+ ]
536
+ notes.sample
537
+ end
538
+ end
539
+ end
540
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CloverSandboxSimulator
4
+ module Services
5
+ # Base service for all API interactions
6
+ # Provides HTTP client, logging, and error handling
7
+ class BaseService
8
+ attr_reader :config, :logger
9
+
10
+ def initialize(config: nil)
11
+ @config = config || CloverSandboxSimulator.configuration
12
+ @config.validate!
13
+ @logger = @config.logger
14
+ end
15
+
16
+ protected
17
+
18
+ # Make HTTP request to Clover API
19
+ #
20
+ # @param method [Symbol] HTTP method (:get, :post, :put, :delete)
21
+ # @param path [String] API endpoint path
22
+ # @param payload [Hash, nil] Request body for POST/PUT
23
+ # @param params [Hash, nil] Query parameters
24
+ # @return [Hash, nil] Parsed JSON response
25
+ def request(method, path, payload: nil, params: nil)
26
+ url = build_url(path, params)
27
+
28
+ log_request(method, url, payload)
29
+ start_time = Time.now
30
+
31
+ response = execute_request(method, url, payload)
32
+
33
+ duration_ms = ((Time.now - start_time) * 1000).round(2)
34
+ log_response(response, duration_ms)
35
+
36
+ parse_response(response)
37
+ rescue RestClient::ExceptionWithResponse => e
38
+ handle_api_error(e)
39
+ rescue StandardError => e
40
+ logger.error "Request failed: #{e.message}"
41
+ raise ApiError, e.message
42
+ end
43
+
44
+ # Build endpoint path with merchant ID
45
+ #
46
+ # @param path [String] Relative path after merchant ID
47
+ # @return [String] Full endpoint path
48
+ def endpoint(path)
49
+ "v3/merchants/#{config.merchant_id}/#{path}"
50
+ end
51
+
52
+ private
53
+
54
+ def headers
55
+ {
56
+ "Authorization" => "Bearer #{config.api_token}",
57
+ "Content-Type" => "application/json",
58
+ "Accept" => "application/json"
59
+ }
60
+ end
61
+
62
+ def build_url(path, params = nil)
63
+ base = path.start_with?("http") ? path : "#{config.environment}#{path}"
64
+ return base unless params&.any?
65
+
66
+ uri = URI(base)
67
+ uri.query = URI.encode_www_form(params)
68
+ uri.to_s
69
+ end
70
+
71
+ def execute_request(method, url, payload)
72
+ case method
73
+ when :get then RestClient.get(url, headers)
74
+ when :post then RestClient.post(url, payload&.to_json, headers)
75
+ when :put then RestClient.put(url, payload&.to_json, headers)
76
+ when :delete then RestClient.delete(url, headers)
77
+ else raise ArgumentError, "Unsupported HTTP method: #{method}"
78
+ end
79
+ end
80
+
81
+ def parse_response(response)
82
+ return nil if response.body.nil? || response.body.empty?
83
+
84
+ JSON.parse(response.body)
85
+ rescue JSON::ParserError => e
86
+ logger.error "Failed to parse response: #{e.message}"
87
+ raise ApiError, "Invalid JSON response"
88
+ end
89
+
90
+ def handle_api_error(error)
91
+ body = begin
92
+ JSON.parse(error.response.body)
93
+ rescue StandardError
94
+ { "message" => error.response.body }
95
+ end
96
+
97
+ logger.error "API Error (#{error.http_code}): #{body}"
98
+ raise ApiError, "HTTP #{error.http_code}: #{body["message"] || body}"
99
+ end
100
+
101
+ def log_request(method, url, payload)
102
+ logger.debug "→ #{method.to_s.upcase} #{url}"
103
+ logger.debug " Payload: #{payload.inspect}" if payload
104
+ end
105
+
106
+ def log_response(response, duration_ms)
107
+ logger.debug "← #{response.code} (#{duration_ms}ms)"
108
+ end
109
+ end
110
+ end
111
+ end