lightspeed_sandbox_simulator 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.
Files changed (53) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +5 -0
  3. data/LICENSE +21 -0
  4. data/README.md +215 -0
  5. data/bin/simulate +162 -0
  6. data/lib/lightspeed_sandbox_simulator/configuration.rb +151 -0
  7. data/lib/lightspeed_sandbox_simulator/data/bar_nightclub/categories.json +9 -0
  8. data/lib/lightspeed_sandbox_simulator/data/bar_nightclub/items.json +26 -0
  9. data/lib/lightspeed_sandbox_simulator/data/bar_nightclub/tenders.json +8 -0
  10. data/lib/lightspeed_sandbox_simulator/data/cafe_bakery/categories.json +9 -0
  11. data/lib/lightspeed_sandbox_simulator/data/cafe_bakery/items.json +28 -0
  12. data/lib/lightspeed_sandbox_simulator/data/cafe_bakery/tenders.json +8 -0
  13. data/lib/lightspeed_sandbox_simulator/data/restaurant/categories.json +9 -0
  14. data/lib/lightspeed_sandbox_simulator/data/restaurant/items.json +29 -0
  15. data/lib/lightspeed_sandbox_simulator/data/restaurant/tenders.json +9 -0
  16. data/lib/lightspeed_sandbox_simulator/data/retail_general/categories.json +9 -0
  17. data/lib/lightspeed_sandbox_simulator/data/retail_general/items.json +17 -0
  18. data/lib/lightspeed_sandbox_simulator/data/retail_general/tenders.json +8 -0
  19. data/lib/lightspeed_sandbox_simulator/database.rb +116 -0
  20. data/lib/lightspeed_sandbox_simulator/db/factories/api_requests.rb +10 -0
  21. data/lib/lightspeed_sandbox_simulator/db/factories/business_types.rb +9 -0
  22. data/lib/lightspeed_sandbox_simulator/db/factories/categories.rb +9 -0
  23. data/lib/lightspeed_sandbox_simulator/db/factories/items.rb +11 -0
  24. data/lib/lightspeed_sandbox_simulator/db/factories/simulated_orders.rb +17 -0
  25. data/lib/lightspeed_sandbox_simulator/db/factories/simulated_payments.rb +13 -0
  26. data/lib/lightspeed_sandbox_simulator/db/migrate/20260313000001_enable_pgcrypto.rb +7 -0
  27. data/lib/lightspeed_sandbox_simulator/db/migrate/20260313000002_create_business_types.rb +14 -0
  28. data/lib/lightspeed_sandbox_simulator/db/migrate/20260313000003_create_categories.rb +13 -0
  29. data/lib/lightspeed_sandbox_simulator/db/migrate/20260313000004_create_items.rb +14 -0
  30. data/lib/lightspeed_sandbox_simulator/db/migrate/20260313000005_create_simulated_orders.rb +23 -0
  31. data/lib/lightspeed_sandbox_simulator/db/migrate/20260313000006_create_simulated_payments.rb +16 -0
  32. data/lib/lightspeed_sandbox_simulator/db/migrate/20260313000007_create_api_requests.rb +18 -0
  33. data/lib/lightspeed_sandbox_simulator/db/migrate/20260313000008_create_daily_summaries.rb +20 -0
  34. data/lib/lightspeed_sandbox_simulator/generators/data_loader.rb +75 -0
  35. data/lib/lightspeed_sandbox_simulator/generators/entity_generator.rb +96 -0
  36. data/lib/lightspeed_sandbox_simulator/generators/order_generator.rb +293 -0
  37. data/lib/lightspeed_sandbox_simulator/models/api_request.rb +9 -0
  38. data/lib/lightspeed_sandbox_simulator/models/business_type.rb +12 -0
  39. data/lib/lightspeed_sandbox_simulator/models/category.rb +12 -0
  40. data/lib/lightspeed_sandbox_simulator/models/daily_summary.rb +44 -0
  41. data/lib/lightspeed_sandbox_simulator/models/item.rb +11 -0
  42. data/lib/lightspeed_sandbox_simulator/models/simulated_order.rb +15 -0
  43. data/lib/lightspeed_sandbox_simulator/models/simulated_payment.rb +13 -0
  44. data/lib/lightspeed_sandbox_simulator/seeder.rb +79 -0
  45. data/lib/lightspeed_sandbox_simulator/services/base_service.rb +158 -0
  46. data/lib/lightspeed_sandbox_simulator/services/lightspeed/business_service.rb +21 -0
  47. data/lib/lightspeed_sandbox_simulator/services/lightspeed/menu_service.rb +70 -0
  48. data/lib/lightspeed_sandbox_simulator/services/lightspeed/order_service.rb +74 -0
  49. data/lib/lightspeed_sandbox_simulator/services/lightspeed/payment_method_service.rb +30 -0
  50. data/lib/lightspeed_sandbox_simulator/services/lightspeed/payment_service.rb +55 -0
  51. data/lib/lightspeed_sandbox_simulator/services/lightspeed/services_manager.rb +54 -0
  52. data/lib/lightspeed_sandbox_simulator.rb +30 -0
  53. metadata +332 -0
@@ -0,0 +1,293 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LightspeedSandboxSimulator
4
+ module Generators
5
+ class OrderGenerator
6
+ MEAL_PERIODS = {
7
+ breakfast: { weight: 15, items_range: 1..3 },
8
+ lunch: { weight: 30, items_range: 2..4 },
9
+ happy_hour: { weight: 10, items_range: 2..4 },
10
+ dinner: { weight: 35, items_range: 3..6 },
11
+ late_night: { weight: 10, items_range: 1..3 }
12
+ }.freeze
13
+
14
+ DINING_OPTIONS = {
15
+ breakfast: { eat_in: 40, takeaway: 50, delivery: 10 },
16
+ lunch: { eat_in: 35, takeaway: 45, delivery: 20 },
17
+ happy_hour: { eat_in: 80, takeaway: 15, delivery: 5 },
18
+ dinner: { eat_in: 70, takeaway: 15, delivery: 15 },
19
+ late_night: { eat_in: 50, takeaway: 30, delivery: 20 }
20
+ }.freeze
21
+
22
+ DAY_RANGES = {
23
+ 0 => 50..80, # Sunday
24
+ 1 => 40..60, # Monday
25
+ 2 => 40..60, # Tuesday
26
+ 3 => 40..60, # Wednesday
27
+ 4 => 40..60, # Thursday
28
+ 5 => 70..100, # Friday
29
+ 6 => 80..120 # Saturday
30
+ }.freeze
31
+
32
+ def initialize(config: nil, business_type: :restaurant, refund_percentage: 5)
33
+ @config = config || LightspeedSandboxSimulator.configuration
34
+ @business_type = business_type
35
+ @refund_percentage = refund_percentage
36
+ @manager = Services::Lightspeed::ServicesManager.new(config: @config)
37
+ end
38
+
39
+ def generate_today(count: nil)
40
+ data = fetch_required_data
41
+ count ||= daily_order_count
42
+ orders = []
43
+
44
+ distribution = distribute_across_periods(count)
45
+ distribution.each do |period, period_count|
46
+ period_count.times do
47
+ order = generate_single_order(data, period: period)
48
+ orders << order if order
49
+ end
50
+ end
51
+
52
+ process_refunds(orders) if @refund_percentage.positive?
53
+ generate_summary
54
+
55
+ orders
56
+ end
57
+
58
+ def generate_realistic_day(multiplier: 1.0)
59
+ count = (daily_order_count * multiplier).round
60
+ generate_today(count: count)
61
+ end
62
+
63
+ def generate_rush(period: :dinner, count: 10)
64
+ data = fetch_required_data
65
+ orders = []
66
+
67
+ count.times do
68
+ order = generate_single_order(data, period: period)
69
+ orders << order if order
70
+ end
71
+
72
+ orders
73
+ end
74
+
75
+ private
76
+
77
+ def fetch_required_data
78
+ items = @manager.menu.list_items
79
+ items = items['items'] if items.is_a?(Hash) && items.key?('items')
80
+ items = Array(items)
81
+ raise Error, 'No items found. Run setup first.' if items.empty?
82
+
83
+ payment_methods = @manager.payment_methods.list_payment_methods
84
+ payment_methods = payment_methods['paymentMethods'] if payment_methods.is_a?(Hash)
85
+ payment_methods = Array(payment_methods)
86
+ raise Error, 'No payment methods found. Run setup first.' if payment_methods.empty?
87
+
88
+ tenders_config = DataLoader.new(business_type: @business_type).load_tenders
89
+ { items: items, payment_methods: payment_methods, tenders_config: tenders_config }
90
+ end
91
+
92
+ def generate_single_order(data, period: :lunch)
93
+ return nil if data[:items].empty?
94
+
95
+ dining_option = select_dining_option(period)
96
+ order_items = select_items(data[:items], period)
97
+ subtotal = order_items.sum { |i| (i[:unit_price] || 0) * (i[:quantity] || 1) }
98
+ tax = (subtotal * @config.tax_rate / 100.0).round(2)
99
+ discount = calculate_discount(subtotal)
100
+ tip = calculate_tip(subtotal, dining_option)
101
+ total = subtotal + tax - discount + tip
102
+
103
+ order_type = dining_option == :eat_in ? :local : :to_go
104
+ result = create_order(order_type, order_items)
105
+ return nil unless result
106
+
107
+ payment_method = select_payment_method(data[:payment_methods], data[:tenders_config])
108
+ create_payment(result, total, tip, payment_method)
109
+ persist_order(result, period, dining_option, tax, tip, discount, total, payment_method)
110
+
111
+ result
112
+ rescue ApiError => e
113
+ LightspeedSandboxSimulator.logger.warn("Order failed: #{e.message}")
114
+ nil
115
+ end
116
+
117
+ def create_order(order_type, items)
118
+ if order_type == :local
119
+ @manager.orders.create_local_order(items: items, table_number: rand(1..20))
120
+ else
121
+ @manager.orders.create_to_go_order(items: items)
122
+ end
123
+ end
124
+
125
+ def create_payment(order_result, total, tip, payment_method)
126
+ order_id = order_result['id'] || order_result['orderId']
127
+ return unless order_id
128
+
129
+ method_id = payment_method['id']
130
+ return unless method_id
131
+
132
+ @manager.payments.create_payment(
133
+ order_id: order_id,
134
+ amount: total,
135
+ tip_amount: tip,
136
+ payment_method_id: method_id
137
+ )
138
+ end
139
+
140
+ def select_items(items, period)
141
+ config = MEAL_PERIODS[period] || MEAL_PERIODS[:lunch]
142
+ count = rand(config[:items_range])
143
+ items.sample(count).map do |item|
144
+ { item_id: item['id'], quantity: 1, unit_price: item['price'].to_f }
145
+ end
146
+ end
147
+
148
+ def select_dining_option(period)
149
+ weights = DINING_OPTIONS[period] || DINING_OPTIONS[:lunch]
150
+ weighted_select(weights)
151
+ end
152
+
153
+ def select_payment_method(payment_methods, tenders_config)
154
+ return payment_methods.first if tenders_config.empty?
155
+
156
+ selected_name = weighted_select_tender(tenders_config)
157
+ matched = payment_methods.find { |m| m['name']&.downcase == selected_name.downcase }
158
+ matched || payment_methods.first
159
+ end
160
+
161
+ def weighted_select_tender(tenders_config)
162
+ total = tenders_config.sum { |t| t['weight'].to_i }
163
+ return tenders_config.first['name'] if total.zero?
164
+
165
+ roll = rand(total)
166
+ cumulative = 0
167
+ tenders_config.each do |tender|
168
+ cumulative += tender['weight'].to_i
169
+ return tender['name'] if roll < cumulative
170
+ end
171
+
172
+ # :nocov:
173
+ tenders_config.last&.dig('name')
174
+ # :nocov:
175
+ end
176
+
177
+ def weighted_select(weights)
178
+ total = weights.values.sum
179
+ return weights.keys.last if total.zero?
180
+
181
+ roll = rand(total)
182
+ cumulative = 0
183
+ weights.each do |key, weight|
184
+ cumulative += weight
185
+ return key if roll < cumulative
186
+ end
187
+
188
+ # :nocov:
189
+ weights.keys.last
190
+ # :nocov:
191
+ end
192
+
193
+ def calculate_discount(subtotal)
194
+ return 0.0 if rand(100) >= 8
195
+
196
+ percentage = rand(10..20) / 100.0
197
+ (subtotal * percentage).round(2)
198
+ end
199
+
200
+ def calculate_tip(subtotal, dining_option)
201
+ tip_config = case dining_option
202
+ when :eat_in then { chance: 70, min: 15, max: 25 }
203
+ when :takeaway then { chance: 20, min: 5, max: 15 }
204
+ when :delivery then { chance: 50, min: 10, max: 20 }
205
+ else { chance: 30, min: 10, max: 20 }
206
+ end
207
+
208
+ return 0.0 if rand(100) >= tip_config[:chance]
209
+
210
+ percentage = rand(tip_config[:min]..tip_config[:max]) / 100.0
211
+ (subtotal * percentage).round(2)
212
+ end
213
+
214
+ def distribute_across_periods(total)
215
+ distribution = {}
216
+
217
+ MEAL_PERIODS.each do |period, config|
218
+ count = (total * config[:weight] / 100.0).round
219
+ distribution[period] = count
220
+ end
221
+
222
+ diff = total - distribution.values.sum
223
+ distribution[:dinner] += diff
224
+
225
+ distribution
226
+ end
227
+
228
+ def daily_order_count
229
+ range = DAY_RANGES[Date.today.wday] || (40..60)
230
+ rand(range)
231
+ end
232
+
233
+ def process_refunds(orders)
234
+ return if orders.empty?
235
+
236
+ refund_count = (orders.size * @refund_percentage / 100.0).ceil
237
+ return if refund_count.zero?
238
+
239
+ orders.sample(refund_count).each do |order|
240
+ order_id = order['id']
241
+ next unless order_id
242
+
243
+ mark_order_refunded(order_id)
244
+ end
245
+ end
246
+
247
+ def mark_order_refunded(order_id)
248
+ return unless Database.connected?
249
+
250
+ record = Models::SimulatedOrder.find_by(order_id: order_id.to_s)
251
+ record&.update!(status: 'refunded')
252
+ rescue StandardError => e
253
+ LightspeedSandboxSimulator.logger.debug("Refund record failed: #{e.message}")
254
+ end
255
+
256
+ def persist_order(result, period, dining_option, tax, tip, discount, total, payment_method)
257
+ return unless Database.connected?
258
+
259
+ order = Models::SimulatedOrder.create!(
260
+ order_id: (result['id'] || result['orderId']).to_s,
261
+ order_type: result['orderType'] || 'local',
262
+ meal_period: period.to_s,
263
+ dining_option: dining_option.to_s,
264
+ order_date: Date.today,
265
+ total: (total * 100).round,
266
+ tax_amount: (tax * 100).round,
267
+ tip_amount: (tip * 100).round,
268
+ discount_amount: (discount * 100).round,
269
+ item_count: Array(result['items']).size
270
+ )
271
+
272
+ Models::SimulatedPayment.create!(
273
+ simulated_order: order,
274
+ payment_id: SecureRandom.uuid,
275
+ tender_name: payment_method['name'].to_s,
276
+ tender_type: payment_method['type'].to_s,
277
+ amount: (total * 100).round,
278
+ tip_amount: (tip * 100).round
279
+ )
280
+ rescue StandardError => e
281
+ LightspeedSandboxSimulator.logger.debug("Persist failed: #{e.message}")
282
+ end
283
+
284
+ def generate_summary
285
+ return unless Database.connected?
286
+
287
+ Models::DailySummary.generate_for!(Date.today)
288
+ rescue StandardError => e
289
+ LightspeedSandboxSimulator.logger.debug("Summary generation failed: #{e.message}")
290
+ end
291
+ end
292
+ end
293
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LightspeedSandboxSimulator
4
+ module Models
5
+ class ApiRequest < ActiveRecord::Base
6
+ self.table_name = 'api_requests'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LightspeedSandboxSimulator
4
+ module Models
5
+ class BusinessType < ActiveRecord::Base
6
+ self.table_name = 'business_types'
7
+
8
+ has_many :categories, dependent: :destroy
9
+ has_many :items, through: :categories
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LightspeedSandboxSimulator
4
+ module Models
5
+ class Category < ActiveRecord::Base
6
+ self.table_name = 'categories'
7
+
8
+ belongs_to :business_type
9
+ has_many :items, dependent: :destroy
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LightspeedSandboxSimulator
4
+ module Models
5
+ class DailySummary < ActiveRecord::Base
6
+ self.table_name = 'daily_summaries'
7
+
8
+ class << self
9
+ def generate_for!(date)
10
+ orders = SimulatedOrder.for_date(date)
11
+ paid = orders.paid
12
+ refunded = orders.refunded
13
+
14
+ payments = SimulatedPayment.successful
15
+ .joins(:simulated_order)
16
+ .where(simulated_orders: { order_date: date })
17
+
18
+ breakdown = build_breakdown(paid, payments)
19
+
20
+ summary = find_or_initialize_by(summary_date: date)
21
+ summary.update!(
22
+ order_count: paid.count,
23
+ payment_count: payments.count,
24
+ refund_count: refunded.count,
25
+ total_revenue: paid.sum(:total),
26
+ total_tax: paid.sum(:tax_amount),
27
+ total_tips: paid.sum(:tip_amount),
28
+ total_discounts: paid.sum(:discount_amount),
29
+ breakdown: breakdown
30
+ )
31
+ summary
32
+ end
33
+
34
+ def build_breakdown(orders, payments)
35
+ {
36
+ by_meal_period: orders.group(:meal_period).sum(:total),
37
+ by_dining_option: orders.group(:dining_option).sum(:total),
38
+ by_tender: payments.group(:tender_name).sum(:amount)
39
+ }
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LightspeedSandboxSimulator
4
+ module Models
5
+ class Item < ActiveRecord::Base
6
+ self.table_name = 'items'
7
+
8
+ belongs_to :category
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LightspeedSandboxSimulator
4
+ module Models
5
+ class SimulatedOrder < ActiveRecord::Base
6
+ self.table_name = 'simulated_orders'
7
+
8
+ has_many :simulated_payments, dependent: :destroy
9
+
10
+ scope :for_date, ->(date) { where(order_date: date) }
11
+ scope :paid, -> { where(status: 'paid') }
12
+ scope :refunded, -> { where(status: 'refunded') }
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LightspeedSandboxSimulator
4
+ module Models
5
+ class SimulatedPayment < ActiveRecord::Base
6
+ self.table_name = 'simulated_payments'
7
+
8
+ belongs_to :simulated_order
9
+
10
+ scope :successful, -> { where(status: 'successful') }
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'factory_bot'
4
+
5
+ module LightspeedSandboxSimulator
6
+ class Seeder
7
+ TYPES = {
8
+ restaurant: { name: 'Restaurant', industry: 'food' },
9
+ cafe_bakery: { name: 'Cafe & Bakery', industry: 'food' },
10
+ bar_nightclub: { name: 'Bar & Nightclub', industry: 'food' },
11
+ retail_general: { name: 'Retail General', industry: 'retail' }
12
+ }.freeze
13
+
14
+ class << self
15
+ def seed!(business_type: :restaurant)
16
+ validate_business_type!(business_type)
17
+
18
+ types = business_type == :all ? TYPES.keys : [business_type]
19
+ result = {}
20
+
21
+ types.each do |type|
22
+ result[type] = seed_type(type)
23
+ end
24
+
25
+ result
26
+ end
27
+
28
+ private
29
+
30
+ def validate_business_type!(type)
31
+ return if type == :all || TYPES.key?(type)
32
+
33
+ raise Error, "Unknown business type: #{type}. Valid: #{TYPES.keys.join(', ')}"
34
+ end
35
+
36
+ def seed_type(type)
37
+ bt = find_or_create_business_type(type)
38
+ loader = Generators::DataLoader.new(business_type: type)
39
+
40
+ categories = seed_categories(bt, loader)
41
+ items = seed_items(categories, loader)
42
+
43
+ { business_type: bt, categories: categories.size, items: items.size }
44
+ end
45
+
46
+ def find_or_create_business_type(type)
47
+ config = TYPES[type]
48
+ Models::BusinessType.find_or_create_by!(key: type.to_s) do |bt|
49
+ bt.name = config[:name]
50
+ bt.industry = config[:industry]
51
+ end
52
+ end
53
+
54
+ def seed_categories(btype, loader)
55
+ loader.load_categories.map do |cat|
56
+ btype.categories.find_or_create_by!(name: cat['name']) do |c|
57
+ c.sort_order = cat['sort_order'] || 0
58
+ c.description = cat['description']
59
+ end
60
+ end
61
+ end
62
+
63
+ def seed_items(categories, loader)
64
+ category_map = categories.index_by(&:name)
65
+
66
+ loader.load_items.map do |item_data|
67
+ category = category_map[item_data['category']]
68
+ next unless category
69
+
70
+ category.items.find_or_create_by!(name: item_data['name']) do |i|
71
+ i.price = (item_data['price'].to_f * 100).round
72
+ i.sku = item_data['sku']
73
+ i.taxable = item_data.fetch('taxable', true)
74
+ end
75
+ end.compact
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'httparty'
4
+ require 'json'
5
+ require 'securerandom'
6
+
7
+ module LightspeedSandboxSimulator
8
+ module Services
9
+ class BaseService
10
+ API_PREFIX = 'api/v2'
11
+
12
+ attr_reader :config
13
+
14
+ def initialize(config: nil)
15
+ @config = config || LightspeedSandboxSimulator.configuration
16
+ @config.validate!
17
+ end
18
+
19
+ private
20
+
21
+ def request(method, path, params: nil, payload: nil, resource_type: nil, resource_id: nil)
22
+ url = build_url(path, params)
23
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
24
+
25
+ response = execute_request(method, url, payload)
26
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
27
+
28
+ audit_request(method, url, payload, { status: response.code, body: response.body, duration: duration },
29
+ resource_type: resource_type, resource_id: resource_id)
30
+
31
+ handle_error_response(response) unless response.success?
32
+
33
+ parse_response(response)
34
+ rescue HTTParty::Error, Net::OpenTimeout, Net::ReadTimeout, SocketError => e
35
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
36
+ audit_request(method, url, payload, { status: 0, body: e.message, duration: duration },
37
+ resource_type: resource_type, resource_id: resource_id, error: e.message)
38
+ raise ApiError, "HTTP error: #{e.message}"
39
+ end
40
+
41
+ def execute_request(method, url, payload)
42
+ options = { headers: build_headers }
43
+ options[:body] = payload.to_json if payload
44
+
45
+ case method
46
+ when :get then HTTParty.get(url, options)
47
+ when :post then HTTParty.post(url, options)
48
+ when :put then HTTParty.put(url, options)
49
+ when :delete then HTTParty.delete(url, options)
50
+ else raise ApiError, "Unsupported HTTP method: #{method}"
51
+ end
52
+ end
53
+
54
+ def build_headers
55
+ {
56
+ 'Authorization' => "Bearer #{config.auth_token}",
57
+ 'Accept' => 'application/json',
58
+ 'Content-Type' => 'application/json'
59
+ }
60
+ end
61
+
62
+ def build_url(path, params = nil)
63
+ url = if path.start_with?('http')
64
+ path
65
+ else
66
+ "#{config.base_url}/#{path}"
67
+ end
68
+
69
+ return url unless params&.any?
70
+
71
+ query = params.map { |k, v| "#{k}=#{v}" }.join('&')
72
+ "#{url}?#{query}"
73
+ end
74
+
75
+ def endpoint(resource)
76
+ "#{API_PREFIX}/businesses/#{config.business_id}/#{resource}"
77
+ end
78
+
79
+ def parse_response(response)
80
+ body = response.body
81
+ return nil if body.nil? || body.empty?
82
+
83
+ JSON.parse(body)
84
+ rescue JSON::ParserError
85
+ raise ApiError, "Invalid JSON response: #{body[0..200]}"
86
+ end
87
+
88
+ def handle_error_response(response)
89
+ body = begin
90
+ JSON.parse(response.body)
91
+ rescue JSON::ParserError, TypeError
92
+ response.body
93
+ end
94
+
95
+ raise ApiError, "API error #{response.code}: #{body}"
96
+ end
97
+
98
+ def fetch_all_pages(resource, params: {})
99
+ all_items = []
100
+ cursor = nil
101
+
102
+ loop do
103
+ request_params = params.merge({})
104
+ request_params[:cursor] = cursor if cursor
105
+
106
+ result = request(:get, endpoint(resource), params: request_params)
107
+ return all_items unless result.is_a?(Hash)
108
+
109
+ items_key = result.keys.find { |k| result[k].is_a?(Array) }
110
+ items = items_key ? result[items_key] : []
111
+ all_items.concat(items)
112
+
113
+ cursor = result['cursor']
114
+ break if cursor.nil? || cursor.empty? || items.empty?
115
+ end
116
+
117
+ all_items
118
+ end
119
+
120
+ def with_api_fallback(fallback: nil)
121
+ yield
122
+ rescue ApiError, StandardError => e
123
+ LightspeedSandboxSimulator.logger.warn("API fallback: #{e.message}")
124
+ fallback
125
+ end
126
+
127
+ def safe_dig(hash, *keys, default: nil)
128
+ return default unless hash.respond_to?(:dig)
129
+
130
+ hash.dig(*keys) || default
131
+ rescue StandardError
132
+ default
133
+ end
134
+
135
+ def audit_request(method, url, payload, response, resource_type: nil, resource_id: nil, error: nil)
136
+ return unless Database.connected?
137
+
138
+ Models::ApiRequest.create!(
139
+ http_method: method.to_s.upcase,
140
+ url: url,
141
+ request_payload: payload&.to_json,
142
+ response_status: response[:status],
143
+ response_payload: truncate_body(response[:body]),
144
+ duration_ms: (response[:duration] * 1000).round,
145
+ resource_type: resource_type,
146
+ resource_id: resource_id,
147
+ error_message: error
148
+ )
149
+ rescue StandardError => e
150
+ LightspeedSandboxSimulator.logger.debug("Audit log failed: #{e.message}")
151
+ end
152
+
153
+ def truncate_body(body)
154
+ body.is_a?(String) ? body[0..10_000] : body.to_json[0..10_000]
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LightspeedSandboxSimulator
4
+ module Services
5
+ module Lightspeed
6
+ class BusinessService < BaseService
7
+ def fetch_business
8
+ request(:get, "#{API_PREFIX}/businesses/#{config.business_id}")
9
+ end
10
+
11
+ def list_tax_rates
12
+ request(:get, endpoint('tax-rates'))
13
+ end
14
+
15
+ def list_floors
16
+ request(:get, endpoint('floorplans'))
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end