skytab_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 (54) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +10 -0
  3. data/bin/simulate +433 -0
  4. data/lib/skytab_sandbox_simulator/configuration.rb +205 -0
  5. data/lib/skytab_sandbox_simulator/data/bar_nightclub/categories.json +9 -0
  6. data/lib/skytab_sandbox_simulator/data/bar_nightclub/items.json +28 -0
  7. data/lib/skytab_sandbox_simulator/data/bar_nightclub/tenders.json +19 -0
  8. data/lib/skytab_sandbox_simulator/data/cafe_bakery/categories.json +9 -0
  9. data/lib/skytab_sandbox_simulator/data/cafe_bakery/items.json +30 -0
  10. data/lib/skytab_sandbox_simulator/data/cafe_bakery/tenders.json +17 -0
  11. data/lib/skytab_sandbox_simulator/data/fine_dining/categories.json +9 -0
  12. data/lib/skytab_sandbox_simulator/data/fine_dining/items.json +30 -0
  13. data/lib/skytab_sandbox_simulator/data/fine_dining/tenders.json +18 -0
  14. data/lib/skytab_sandbox_simulator/data/pizzeria/categories.json +9 -0
  15. data/lib/skytab_sandbox_simulator/data/pizzeria/items.json +28 -0
  16. data/lib/skytab_sandbox_simulator/data/pizzeria/tenders.json +18 -0
  17. data/lib/skytab_sandbox_simulator/data/restaurant/categories.json +44 -0
  18. data/lib/skytab_sandbox_simulator/data/restaurant/items.json +59 -0
  19. data/lib/skytab_sandbox_simulator/data/restaurant/tenders.json +22 -0
  20. data/lib/skytab_sandbox_simulator/database.rb +192 -0
  21. data/lib/skytab_sandbox_simulator/db/factories/business_types.rb +102 -0
  22. data/lib/skytab_sandbox_simulator/db/factories/categories.rb +243 -0
  23. data/lib/skytab_sandbox_simulator/db/factories/items.rb +976 -0
  24. data/lib/skytab_sandbox_simulator/db/factories/simulated_orders.rb +120 -0
  25. data/lib/skytab_sandbox_simulator/db/factories/simulated_payments.rb +75 -0
  26. data/lib/skytab_sandbox_simulator/db/migrate/20260316000000_enable_pgcrypto.rb +7 -0
  27. data/lib/skytab_sandbox_simulator/db/migrate/20260316000001_create_business_types.rb +18 -0
  28. data/lib/skytab_sandbox_simulator/db/migrate/20260316000002_create_categories.rb +18 -0
  29. data/lib/skytab_sandbox_simulator/db/migrate/20260316000003_create_items.rb +23 -0
  30. data/lib/skytab_sandbox_simulator/db/migrate/20260316000004_create_simulated_orders.rb +35 -0
  31. data/lib/skytab_sandbox_simulator/db/migrate/20260316000005_create_simulated_payments.rb +26 -0
  32. data/lib/skytab_sandbox_simulator/db/migrate/20260316000006_create_api_requests.rb +27 -0
  33. data/lib/skytab_sandbox_simulator/db/migrate/20260316000007_create_daily_summaries.rb +24 -0
  34. data/lib/skytab_sandbox_simulator/generators/data_loader.rb +125 -0
  35. data/lib/skytab_sandbox_simulator/generators/entity_generator.rb +107 -0
  36. data/lib/skytab_sandbox_simulator/generators/order_generator.rb +390 -0
  37. data/lib/skytab_sandbox_simulator/models/api_request.rb +43 -0
  38. data/lib/skytab_sandbox_simulator/models/business_type.rb +25 -0
  39. data/lib/skytab_sandbox_simulator/models/category.rb +17 -0
  40. data/lib/skytab_sandbox_simulator/models/daily_summary.rb +67 -0
  41. data/lib/skytab_sandbox_simulator/models/item.rb +32 -0
  42. data/lib/skytab_sandbox_simulator/models/record.rb +14 -0
  43. data/lib/skytab_sandbox_simulator/models/simulated_order.rb +40 -0
  44. data/lib/skytab_sandbox_simulator/models/simulated_payment.rb +28 -0
  45. data/lib/skytab_sandbox_simulator/seeder.rb +167 -0
  46. data/lib/skytab_sandbox_simulator/services/base_service.rb +227 -0
  47. data/lib/skytab_sandbox_simulator/services/skytab/catalog_service.rb +130 -0
  48. data/lib/skytab_sandbox_simulator/services/skytab/location_service.rb +54 -0
  49. data/lib/skytab_sandbox_simulator/services/skytab/order_service.rb +139 -0
  50. data/lib/skytab_sandbox_simulator/services/skytab/payment_service.rb +94 -0
  51. data/lib/skytab_sandbox_simulator/services/skytab/service_manager.rb +62 -0
  52. data/lib/skytab_sandbox_simulator/version.rb +5 -0
  53. data/lib/skytab_sandbox_simulator.rb +45 -0
  54. metadata +305 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 34ae23cd46e19a75fe126e54846f50c9e0f3641be805762af8bf9ee8d8c820c5
4
+ data.tar.gz: 40b36d73dccbd5de18a4ab1d0726c53f2f457ef8cb855ed65d55ff68ea5752c9
5
+ SHA512:
6
+ metadata.gz: be9f3f2b773aa3cf624a07fc8ac958f28af03ac2383b26ae902c37d83c5380a1bcc8d8ec8d6fa61af78a4c635eed88a1ecc0d36b86f8e31f6b81ed0550c7089b
7
+ data.tar.gz: 91972a8d53fbbc80981b6a93b0f6f95eb9f701accec86f9226526943328508b44724415b0a51f64a1e0464b211e7175844349b766d923e3f1ab348c7651ffded
data/Gemfile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec
6
+
7
+ group :development, :test do
8
+ gem "pry", "~> 0.14"
9
+ gem "pry-byebug", "~> 3.10"
10
+ end
data/bin/simulate ADDED
@@ -0,0 +1,433 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "skytab_sandbox_simulator"
6
+ require "thor"
7
+
8
+ module SkytabSandboxSimulator
9
+ # Command-line interface for SkyTab Sandbox Simulator
10
+ class CLI < Thor
11
+ class_option :verbose, type: :boolean, aliases: "-v", desc: "Enable verbose logging"
12
+ class_option :location, type: :string, aliases: "-l", desc: "Location ID to use (from .env.json)"
13
+ class_option :location_index, type: :numeric, aliases: "-i", desc: "Location index to use (0-based, from .env.json)"
14
+
15
+ desc "locations", "List available locations from .env.json"
16
+ def locations
17
+ puts "SkyTab Sandbox Simulator - Available Locations"
18
+ puts "=" * 60
19
+
20
+ config = SkytabSandboxSimulator.configuration
21
+ locs = config.available_locations
22
+
23
+ if locs.empty?
24
+ puts "No locations found in .env.json"
25
+ return
26
+ end
27
+
28
+ puts "\n#{'Index'.ljust(6)} #{'Location ID'.ljust(16)} #{'Name'.ljust(25)} #{'Timezone'}"
29
+ puts "-" * 70
30
+
31
+ locs.each_with_index do |loc, idx|
32
+ tz = loc[:timezone] || "N/A"
33
+ puts "#{idx.to_s.ljust(6)} #{(loc[:id] || 'N/A').ljust(16)} #{(loc[:name] || 'N/A')[0..23].ljust(25)} #{tz}"
34
+ end
35
+
36
+ puts "\nUse: simulate <command> -i <index> or -l <location_id>"
37
+ end
38
+
39
+ desc "setup", "Set up entities (categories, items)"
40
+ option :business_type, type: :string, default: "restaurant", desc: "Business type"
41
+ def setup
42
+ configure_logging
43
+
44
+ puts "SkyTab Sandbox Simulator - Entity Setup"
45
+ puts "=" * 50
46
+
47
+ generator = Generators::EntityGenerator.new(business_type: options[:business_type].to_sym)
48
+ generator.setup_all
49
+
50
+ puts "\nSetup complete!"
51
+ end
52
+
53
+ desc "generate", "Generate orders for today"
54
+ option :count, type: :numeric, aliases: "-n", desc: "Number of orders to generate"
55
+ option :refund_percentage, type: :numeric, aliases: "-r", default: 5,
56
+ desc: "Percentage of orders to refund (0-100, default 5)"
57
+ def generate
58
+ configure_logging
59
+
60
+ puts "SkyTab Sandbox Simulator - Order Generation"
61
+ puts "=" * 50
62
+
63
+ generator = Generators::OrderGenerator.new(refund_percentage: options[:refund_percentage])
64
+ orders = generator.generate_today(count: options[:count])
65
+
66
+ puts "\nGenerated #{orders.size} orders!"
67
+ end
68
+
69
+ desc "day", "Generate a realistic full day of operations"
70
+ option :multiplier, type: :numeric, aliases: "-x", default: 1.0, desc: "Order multiplier"
71
+ option :refund_percentage, type: :numeric, aliases: "-r", default: 5,
72
+ desc: "Percentage of orders to refund"
73
+ def day
74
+ configure_logging
75
+
76
+ puts "SkyTab Sandbox Simulator - Realistic Day"
77
+ puts "=" * 50
78
+
79
+ generator = Generators::OrderGenerator.new(refund_percentage: options[:refund_percentage])
80
+ orders = generator.generate_realistic_day(multiplier: options[:multiplier])
81
+
82
+ puts "\nGenerated #{orders.size} orders!"
83
+ end
84
+
85
+ desc "full", "Run full simulation (setup + generate orders)"
86
+ option :count, type: :numeric, aliases: "-n", desc: "Number of orders to generate"
87
+ option :business_type, type: :string, default: "restaurant", desc: "Business type"
88
+ option :refund_percentage, type: :numeric, aliases: "-r", default: 5,
89
+ desc: "Percentage of orders to refund"
90
+ def full
91
+ configure_logging
92
+
93
+ puts "SkyTab Sandbox Simulator - Full Simulation"
94
+ puts "=" * 50
95
+
96
+ # Setup entities
97
+ entity_gen = Generators::EntityGenerator.new(business_type: options[:business_type].to_sym)
98
+ entity_gen.setup_all
99
+
100
+ puts "\n"
101
+
102
+ # Generate orders
103
+ order_gen = Generators::OrderGenerator.new(refund_percentage: options[:refund_percentage])
104
+ orders = order_gen.generate_today(count: options[:count])
105
+
106
+ puts "\nFull simulation complete!"
107
+ puts " Orders generated: #{orders.size}"
108
+ end
109
+
110
+ desc "status", "Show current SkyTab location status"
111
+ def status
112
+ configure_logging
113
+
114
+ puts "SkyTab Sandbox Simulator - Status"
115
+ puts "=" * 50
116
+
117
+ services = Services::Skytab::ServiceManager.new
118
+
119
+ location = services.location.get_current_location
120
+ if location
121
+ puts "\nLocation:"
122
+ puts " ID: #{location['id']}"
123
+ puts " Name: #{location['name']}"
124
+ puts " Timezone: #{location['timeZone']}"
125
+ puts " Currency: #{location['currency']}"
126
+ end
127
+
128
+ categories = services.catalog.get_categories
129
+ items = services.catalog.get_items
130
+ taxes = services.catalog.get_taxes
131
+
132
+ puts "\nEntity Counts:"
133
+ puts " Categories: #{categories.size}"
134
+ puts " Items: #{items.size}"
135
+ puts " Tax Rates: #{taxes.size}"
136
+
137
+ puts "\nCategories:"
138
+ categories.each { |c| puts " - #{c['name']}" }
139
+
140
+ puts "\nTax Rates:"
141
+ taxes.each do |t|
142
+ rate_pct = (t["rate"] || 0) / 10_000.0
143
+ puts " - #{t['name']}: #{rate_pct}%"
144
+ end
145
+ end
146
+
147
+ desc "delete", "Delete all entities (use with caution!)"
148
+ option :confirm, type: :boolean, desc: "Confirm deletion"
149
+ def delete
150
+ unless options[:confirm]
151
+ puts "This will delete ALL entities from SkyTab!"
152
+ puts " Run with --confirm to proceed."
153
+ return
154
+ end
155
+
156
+ configure_logging
157
+
158
+ puts "Deleting all entities..."
159
+
160
+ generator = Generators::EntityGenerator.new
161
+ generator.delete_all
162
+
163
+ puts "\nAll entities deleted!"
164
+ end
165
+
166
+ desc "version", "Show version"
167
+ def version
168
+ puts "SkyTab Sandbox Simulator v#{SkytabSandboxSimulator::VERSION}"
169
+ end
170
+
171
+ # ============================================
172
+ # Database Management Commands (db: namespace)
173
+ # ============================================
174
+
175
+ desc "db SUBCOMMAND", "Database management commands"
176
+ subcommand "db", Class.new(Thor) {
177
+ def self.banner(task, namespace = true, subcommand = true)
178
+ "simulate db #{task.usage}"
179
+ end
180
+
181
+ namespace "db"
182
+
183
+ desc "create", "Create the PostgreSQL database"
184
+ def create
185
+ db = SkytabSandboxSimulator::Database
186
+ url = db.database_url
187
+ puts "Creating database..."
188
+ db.create!(url)
189
+ puts "Done."
190
+ rescue SkytabSandboxSimulator::Error => e
191
+ puts "Error: #{e.message}"
192
+ exit 1
193
+ end
194
+
195
+ desc "migrate", "Run pending migrations"
196
+ def migrate
197
+ db = SkytabSandboxSimulator::Database
198
+ url = db.database_url
199
+ puts "Connecting and running migrations..."
200
+ db.connect!(url)
201
+ db.migrate!
202
+ puts "Done."
203
+ rescue SkytabSandboxSimulator::Error => e
204
+ puts "Error: #{e.message}"
205
+ exit 1
206
+ end
207
+
208
+ desc "seed", "Seed the database with realistic data via FactoryBot"
209
+ option :type, type: :string, desc: "Business type to seed (e.g. restaurant, cafe_bakery). Seeds all if omitted."
210
+ def seed
211
+ db = SkytabSandboxSimulator::Database
212
+ url = db.database_url
213
+ puts "Connecting and seeding..."
214
+ db.connect!(url)
215
+ bt = options[:type]&.to_sym
216
+ result = db.seed!(business_type: bt)
217
+ puts "Seeded: #{result[:business_types]} business types, #{result[:categories]} categories, #{result[:items]} items"
218
+ puts " (#{result[:created]} created, #{result[:found]} already existed)"
219
+ rescue SkytabSandboxSimulator::Error => e
220
+ puts "Error: #{e.message}"
221
+ exit 1
222
+ end
223
+
224
+ desc "reset", "Drop, create, migrate, and seed the database"
225
+ option :type, type: :string, desc: "Business type to seed. Seeds all if omitted."
226
+ option :confirm, type: :boolean, desc: "Confirm destructive operation"
227
+ def reset
228
+ unless options[:confirm]
229
+ puts "This will DROP and recreate the database. All data will be lost."
230
+ puts "Run with --confirm to proceed."
231
+ return
232
+ end
233
+
234
+ db = SkytabSandboxSimulator::Database
235
+ url = db.database_url
236
+
237
+ puts "Dropping database..."
238
+ db.drop!(url)
239
+
240
+ puts "Creating database..."
241
+ db.create!(url)
242
+
243
+ puts "Connecting and running migrations..."
244
+ db.connect!(url)
245
+ db.migrate!
246
+
247
+ puts "Seeding..."
248
+ bt = options[:type]&.to_sym
249
+ result = db.seed!(business_type: bt)
250
+ puts "Seeded: #{result[:business_types]} business types, #{result[:categories]} categories, #{result[:items]} items"
251
+ puts "Done."
252
+ rescue SkytabSandboxSimulator::Error => e
253
+ puts "Error: #{e.message}"
254
+ exit 1
255
+ end
256
+ }
257
+
258
+ # ============================================
259
+ # Audit & Reporting Commands
260
+ # ============================================
261
+
262
+ desc "summary", "Show daily summary for current location"
263
+ option :date, type: :string, aliases: "-d", desc: "Date (YYYY-MM-DD, default: today)"
264
+ def summary
265
+ configure_logging
266
+ require_db_connection!
267
+
268
+ date = options[:date] ? Date.parse(options[:date]) : Date.today
269
+ location_id = SkytabSandboxSimulator.configuration.location_id
270
+
271
+ s = Models::DailySummary.for_location(location_id).on_date(date).first
272
+
273
+ unless s
274
+ puts "No summary found for location #{location_id} on #{date}."
275
+ puts "Run 'simulate generate' to create orders first."
276
+ return
277
+ end
278
+
279
+ puts "Daily Summary: #{date}"
280
+ puts "=" * 50
281
+ puts " Location: #{location_id}"
282
+ puts " Orders: #{s.order_count}"
283
+ puts " Payments: #{s.payment_count}"
284
+ puts " Refunds: #{s.refund_count}"
285
+ puts ""
286
+ puts " Revenue: #{format_cents(s.total_revenue)}"
287
+ puts " Tax: #{format_cents(s.total_tax)}"
288
+ puts " Tips: #{format_cents(s.total_tips)}"
289
+ puts " Discounts: #{format_cents(s.total_discounts)}"
290
+
291
+ breakdown = s.breakdown || {}
292
+
293
+ if breakdown["by_meal_period"]&.any?
294
+ puts ""
295
+ puts " By Meal Period:"
296
+ breakdown["by_meal_period"].each do |period, count|
297
+ rev = breakdown.dig("revenue_by_meal_period", period) || 0
298
+ puts " #{period.ljust(15)} #{count.to_s.rjust(3)} orders #{format_cents(rev)}"
299
+ end
300
+ end
301
+
302
+ if breakdown["by_revenue_class"]&.any?
303
+ puts ""
304
+ puts " By Revenue Class:"
305
+ breakdown["by_revenue_class"].each do |rc, count|
306
+ rev = breakdown.dig("revenue_by_revenue_class", rc) || 0
307
+ puts " #{rc.ljust(15)} #{count.to_s.rjust(3)} orders #{format_cents(rev)}"
308
+ end
309
+ end
310
+
311
+ if breakdown["by_tender"]&.any?
312
+ puts ""
313
+ puts " By Tender:"
314
+ breakdown["by_tender"].each do |tender, count|
315
+ puts " #{tender.ljust(20)} #{count} payments"
316
+ end
317
+ end
318
+ end
319
+
320
+ desc "audit", "Show recent API requests"
321
+ option :limit, type: :numeric, aliases: "-l", default: 20, desc: "Number of requests to show"
322
+ option :errors, type: :boolean, aliases: "-e", desc: "Show only error requests"
323
+ def audit
324
+ configure_logging
325
+ require_db_connection!
326
+
327
+ scope = Models::ApiRequest.order(created_at: :desc)
328
+ scope = scope.errors if options[:errors]
329
+ requests = scope.limit(options[:limit])
330
+
331
+ if requests.empty?
332
+ label = options[:errors] ? "error " : ""
333
+ puts "No #{label}API requests found."
334
+ return
335
+ end
336
+
337
+ label = options[:errors] ? "Error " : ""
338
+ puts "Recent #{label}API Requests (#{requests.size})"
339
+ puts "=" * 80
340
+
341
+ requests.each do |req|
342
+ status_indicator = req.error_message ? "ERR" : "OK "
343
+ ts = req.created_at.strftime("%H:%M:%S")
344
+ duration = req.duration_ms ? "#{req.duration_ms}ms" : "N/A"
345
+
346
+ puts "#{status_indicator} #{ts} #{req.http_method.ljust(6)} #{req.response_status || '---'} " \
347
+ "#{duration.rjust(7)} #{truncate_url(req.url, 50)}"
348
+
349
+ puts " Error: #{req.error_message[0..80]}" if req.error_message
350
+ end
351
+
352
+ total = Models::ApiRequest.count
353
+ errors = Models::ApiRequest.errors.count
354
+ puts "\nTotal: #{total} requests, #{errors} errors"
355
+ end
356
+
357
+ desc "business_types", "List business types with category and item counts"
358
+ def business_types
359
+ configure_logging
360
+ require_db_connection!
361
+
362
+ types = Models::BusinessType.all.order(:key)
363
+
364
+ if types.empty?
365
+ puts "No business types found. Run 'simulate db seed' first."
366
+ return
367
+ end
368
+
369
+ puts "Business Types"
370
+ puts "=" * 60
371
+ puts "#{'Key'.ljust(20)} #{'Industry'.ljust(10)} #{'Categories'.rjust(10)} #{'Items'.rjust(8)}"
372
+ puts "-" * 60
373
+
374
+ total_cats = 0
375
+ total_items = 0
376
+
377
+ types.each do |bt|
378
+ cat_count = bt.categories.count
379
+ item_count = Models::Item.for_business_type(bt.key).count
380
+ total_cats += cat_count
381
+ total_items += item_count
382
+
383
+ industry = bt.industry || "N/A"
384
+ puts "#{bt.key.ljust(20)} #{industry.ljust(10)} #{cat_count.to_s.rjust(10)} #{item_count.to_s.rjust(8)}"
385
+ end
386
+
387
+ puts "-" * 60
388
+ puts "#{'TOTAL'.ljust(20)} #{' '.ljust(10)} #{total_cats.to_s.rjust(10)} #{total_items.to_s.rjust(8)}"
389
+ end
390
+
391
+ private
392
+
393
+ def format_cents(cents)
394
+ "$#{'%.2f' % ((cents || 0) / 100.0)}"
395
+ end
396
+
397
+ def require_db_connection!
398
+ url = Database.database_url
399
+ Database.connect!(url) unless Database.connected?
400
+ rescue Error => e
401
+ puts "Database not available: #{e.message}"
402
+ puts "Run 'simulate db create && simulate db migrate' first."
403
+ exit 1
404
+ end
405
+
406
+ def truncate_url(url, max_length)
407
+ return url if url.nil? || url.length <= max_length
408
+
409
+ "...#{url[-(max_length - 3)..]}"
410
+ end
411
+
412
+ def configure_logging
413
+ config = SkytabSandboxSimulator.configuration
414
+
415
+ if options[:verbose]
416
+ config.logger.level = Logger::DEBUG
417
+ else
418
+ config.logger.level = Logger::INFO
419
+ end
420
+
421
+ # Load specific location if specified
422
+ if options[:location]
423
+ config.load_location(location_id: options[:location])
424
+ elsif options[:location_index]
425
+ config.load_location(index: options[:location_index])
426
+ end
427
+
428
+ puts "Using location: #{config.location_name} (#{config.location_id})"
429
+ end
430
+ end
431
+ end
432
+
433
+ SkytabSandboxSimulator::CLI.start(ARGV)
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkytabSandboxSimulator
4
+ class Configuration
5
+ attr_accessor :location_id, :location_name, :access_token, :environment, :log_level,
6
+ :tax_rate, :business_type, :merchant_id, :location_timezone,
7
+ :currency, :country_code
8
+
9
+ # Default timezone if not fetched from SkyTab
10
+ DEFAULT_TIMEZONE = "America/New_York"
11
+
12
+ # SkyTab (Shift4) Marketplace API base URL
13
+ DEFAULT_ENVIRONMENT = "https://conecto-api.shift4payments.com/"
14
+
15
+ # Path to locations JSON file
16
+ LOCATIONS_FILE = File.join(File.dirname(__FILE__), "..", "..", ".env.json")
17
+
18
+ def initialize
19
+ @location_id = ENV.fetch("SKYTAB_LOCATION_ID", nil)
20
+ @location_name = ENV.fetch("SKYTAB_LOCATION_NAME", nil)
21
+ @access_token = ENV.fetch("SKYTAB_ACCESS_TOKEN", nil)
22
+ @merchant_id = ENV.fetch("SKYTAB_MERCHANT_ID", nil)
23
+ @environment = normalize_url(ENV.fetch("SKYTAB_ENVIRONMENT", DEFAULT_ENVIRONMENT))
24
+ @log_level = parse_log_level(ENV.fetch("LOG_LEVEL", "INFO"))
25
+ @tax_rate = ENV.fetch("TAX_RATE", "8.25").to_f
26
+ @business_type = ENV.fetch("BUSINESS_TYPE", "restaurant").to_sym
27
+ @location_timezone = ENV.fetch("SKYTAB_TIMEZONE", nil)
28
+ @currency = ENV.fetch("SKYTAB_CURRENCY", "USD")
29
+ @country_code = ENV.fetch("SKYTAB_COUNTRY_CODE", "US")
30
+
31
+ # Load from .env.json if location_id not set in ENV
32
+ load_from_locations_file if @location_id.nil? || @location_id.empty?
33
+ end
34
+
35
+ # Load configuration for a specific location from .env.json
36
+ #
37
+ # @param location_id [String, nil] Location ID to load (nil for first location)
38
+ # @param index [Integer, nil] Index of location in the list (0-based)
39
+ # @return [self]
40
+ def load_location(location_id: nil, index: nil)
41
+ locations = load_locations_file
42
+ return self if locations.empty?
43
+
44
+ location = if location_id
45
+ locations.find { |loc| loc["SKYTAB_LOCATION_ID"] == location_id }
46
+ elsif index
47
+ locations[index]
48
+ else
49
+ locations.first
50
+ end
51
+
52
+ if location
53
+ apply_location_config(location)
54
+ logger.info "Loaded location: #{@location_name} (#{@location_id})"
55
+ else
56
+ logger.warn "Location not found: #{location_id || "index #{index}"}"
57
+ end
58
+
59
+ self
60
+ end
61
+
62
+ # List all available locations from .env.json
63
+ #
64
+ # @return [Array<Hash>] Array of location configs
65
+ def available_locations
66
+ load_locations_file.map do |loc|
67
+ {
68
+ id: loc["SKYTAB_LOCATION_ID"],
69
+ name: loc["SKYTAB_LOCATION_NAME"],
70
+ merchant_id: loc["SKYTAB_MERCHANT_ID"],
71
+ timezone: loc["SKYTAB_TIMEZONE"]
72
+ }
73
+ end
74
+ end
75
+
76
+ def validate!
77
+ raise ConfigurationError, "SKYTAB_LOCATION_ID is required" if location_id.nil? || location_id.empty?
78
+ raise ConfigurationError, "SKYTAB_ACCESS_TOKEN is required" if access_token.nil? || access_token.empty?
79
+
80
+ true
81
+ end
82
+
83
+ def logger
84
+ @logger ||= Logger.new($stdout).tap do |log|
85
+ log.level = @log_level
86
+ log.formatter = proc do |severity, datetime, _progname, msg|
87
+ timestamp = datetime.strftime("%Y-%m-%d %H:%M:%S")
88
+ "[#{timestamp}] #{severity.ljust(5)} | #{msg}\n"
89
+ end
90
+ end
91
+ end
92
+
93
+ # Fetch location timezone from SkyTab API
94
+ # @return [String] IANA timezone identifier (e.g., "America/New_York")
95
+ def fetch_location_timezone
96
+ return @location_timezone if @location_timezone
97
+
98
+ require "rest-client"
99
+ require "json"
100
+
101
+ url = "#{environment}marketplace/v2/locations"
102
+ response = RestClient.get(url, { Authorization: "Bearer #{access_token}" })
103
+ data = JSON.parse(response.body)
104
+
105
+ locations = data.is_a?(Array) ? data : (data["locations"] || [data])
106
+ loc = locations.find { |l| l["id"].to_s == location_id.to_s } || locations.first
107
+
108
+ @location_timezone = loc&.dig("timeZone") || DEFAULT_TIMEZONE
109
+ logger.info "Location timezone: #{@location_timezone}"
110
+ @location_timezone
111
+ rescue StandardError => e
112
+ logger.warn "Failed to fetch location timezone: #{e.message}. Using default: #{DEFAULT_TIMEZONE}"
113
+ @location_timezone = DEFAULT_TIMEZONE
114
+ end
115
+
116
+ # Get current time in location's timezone
117
+ # @return [Time] Current time in location timezone
118
+ def location_time_now
119
+ require "time"
120
+ tz = fetch_location_timezone
121
+ begin
122
+ require "tzinfo"
123
+ TZInfo::Timezone.get(tz).now
124
+ rescue LoadError
125
+ old_tz = ENV["TZ"]
126
+ ENV["TZ"] = tz
127
+ time = Time.now
128
+ ENV["TZ"] = old_tz
129
+ time
130
+ end
131
+ end
132
+
133
+ # Get today's date in location's timezone
134
+ # @return [Date] Today's date in location timezone
135
+ def location_date_today
136
+ location_time_now.to_date
137
+ end
138
+
139
+ # Read the top-level DATABASE_URL from .env.json (object format only).
140
+ # Returns nil when the file uses the legacy array format or has no key.
141
+ #
142
+ # @return [String, nil] The DATABASE_URL or nil
143
+ def self.database_url_from_file
144
+ return nil unless File.exist?(LOCATIONS_FILE)
145
+
146
+ data = JSON.parse(File.read(LOCATIONS_FILE))
147
+ return nil if data.is_a?(Array)
148
+
149
+ data["DATABASE_URL"]
150
+ rescue JSON::ParserError
151
+ nil
152
+ end
153
+
154
+ private
155
+
156
+ def load_from_locations_file
157
+ locations = load_locations_file
158
+ return if locations.empty?
159
+
160
+ # Use first location by default
161
+ apply_location_config(locations.first)
162
+ end
163
+
164
+ # Parse .env.json, supporting both the legacy array format and the
165
+ # new object format: { "DATABASE_URL": "...", "locations": [...] }
166
+ def load_locations_file
167
+ return [] unless File.exist?(LOCATIONS_FILE)
168
+
169
+ data = JSON.parse(File.read(LOCATIONS_FILE))
170
+ return data if data.is_a?(Array) # legacy format
171
+
172
+ # New object format — extract locations list
173
+ data.fetch("locations", [])
174
+ rescue JSON::ParserError => e
175
+ warn "Failed to parse #{LOCATIONS_FILE}: #{e.message}"
176
+ []
177
+ end
178
+
179
+ def apply_location_config(location)
180
+ @location_id = location["SKYTAB_LOCATION_ID"]
181
+ @location_name = location["SKYTAB_LOCATION_NAME"]
182
+ @access_token = location["SKYTAB_ACCESS_TOKEN"]
183
+ @merchant_id = location["SKYTAB_MERCHANT_ID"]
184
+ @location_timezone = location["SKYTAB_TIMEZONE"]
185
+ @currency = location["SKYTAB_CURRENCY"] || "USD"
186
+ @country_code = location["SKYTAB_COUNTRY_CODE"] || "US"
187
+ end
188
+
189
+ def normalize_url(url)
190
+ url = url.strip
191
+ url.end_with?("/") ? url : "#{url}/"
192
+ end
193
+
194
+ def parse_log_level(level)
195
+ case level.to_s.upcase
196
+ when "DEBUG" then Logger::DEBUG
197
+ when "INFO" then Logger::INFO
198
+ when "WARN" then Logger::WARN
199
+ when "ERROR" then Logger::ERROR
200
+ when "FATAL" then Logger::FATAL
201
+ else Logger::INFO
202
+ end
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,9 @@
1
+ {
2
+ "categories": [
3
+ { "name": "Draft Beer", "sort_order": 1, "description": "On tap" },
4
+ { "name": "Cocktails", "sort_order": 2, "description": "Handcrafted cocktails" },
5
+ { "name": "Spirits", "sort_order": 3, "description": "Premium spirits" },
6
+ { "name": "Wine", "sort_order": 4, "description": "By the glass or bottle" },
7
+ { "name": "Bar Snacks", "sort_order": 5, "description": "Shareable bites" }
8
+ ]
9
+ }