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.
- checksums.yaml +7 -0
- data/Gemfile +10 -0
- data/bin/simulate +433 -0
- data/lib/skytab_sandbox_simulator/configuration.rb +205 -0
- data/lib/skytab_sandbox_simulator/data/bar_nightclub/categories.json +9 -0
- data/lib/skytab_sandbox_simulator/data/bar_nightclub/items.json +28 -0
- data/lib/skytab_sandbox_simulator/data/bar_nightclub/tenders.json +19 -0
- data/lib/skytab_sandbox_simulator/data/cafe_bakery/categories.json +9 -0
- data/lib/skytab_sandbox_simulator/data/cafe_bakery/items.json +30 -0
- data/lib/skytab_sandbox_simulator/data/cafe_bakery/tenders.json +17 -0
- data/lib/skytab_sandbox_simulator/data/fine_dining/categories.json +9 -0
- data/lib/skytab_sandbox_simulator/data/fine_dining/items.json +30 -0
- data/lib/skytab_sandbox_simulator/data/fine_dining/tenders.json +18 -0
- data/lib/skytab_sandbox_simulator/data/pizzeria/categories.json +9 -0
- data/lib/skytab_sandbox_simulator/data/pizzeria/items.json +28 -0
- data/lib/skytab_sandbox_simulator/data/pizzeria/tenders.json +18 -0
- data/lib/skytab_sandbox_simulator/data/restaurant/categories.json +44 -0
- data/lib/skytab_sandbox_simulator/data/restaurant/items.json +59 -0
- data/lib/skytab_sandbox_simulator/data/restaurant/tenders.json +22 -0
- data/lib/skytab_sandbox_simulator/database.rb +192 -0
- data/lib/skytab_sandbox_simulator/db/factories/business_types.rb +102 -0
- data/lib/skytab_sandbox_simulator/db/factories/categories.rb +243 -0
- data/lib/skytab_sandbox_simulator/db/factories/items.rb +976 -0
- data/lib/skytab_sandbox_simulator/db/factories/simulated_orders.rb +120 -0
- data/lib/skytab_sandbox_simulator/db/factories/simulated_payments.rb +75 -0
- data/lib/skytab_sandbox_simulator/db/migrate/20260316000000_enable_pgcrypto.rb +7 -0
- data/lib/skytab_sandbox_simulator/db/migrate/20260316000001_create_business_types.rb +18 -0
- data/lib/skytab_sandbox_simulator/db/migrate/20260316000002_create_categories.rb +18 -0
- data/lib/skytab_sandbox_simulator/db/migrate/20260316000003_create_items.rb +23 -0
- data/lib/skytab_sandbox_simulator/db/migrate/20260316000004_create_simulated_orders.rb +35 -0
- data/lib/skytab_sandbox_simulator/db/migrate/20260316000005_create_simulated_payments.rb +26 -0
- data/lib/skytab_sandbox_simulator/db/migrate/20260316000006_create_api_requests.rb +27 -0
- data/lib/skytab_sandbox_simulator/db/migrate/20260316000007_create_daily_summaries.rb +24 -0
- data/lib/skytab_sandbox_simulator/generators/data_loader.rb +125 -0
- data/lib/skytab_sandbox_simulator/generators/entity_generator.rb +107 -0
- data/lib/skytab_sandbox_simulator/generators/order_generator.rb +390 -0
- data/lib/skytab_sandbox_simulator/models/api_request.rb +43 -0
- data/lib/skytab_sandbox_simulator/models/business_type.rb +25 -0
- data/lib/skytab_sandbox_simulator/models/category.rb +17 -0
- data/lib/skytab_sandbox_simulator/models/daily_summary.rb +67 -0
- data/lib/skytab_sandbox_simulator/models/item.rb +32 -0
- data/lib/skytab_sandbox_simulator/models/record.rb +14 -0
- data/lib/skytab_sandbox_simulator/models/simulated_order.rb +40 -0
- data/lib/skytab_sandbox_simulator/models/simulated_payment.rb +28 -0
- data/lib/skytab_sandbox_simulator/seeder.rb +167 -0
- data/lib/skytab_sandbox_simulator/services/base_service.rb +227 -0
- data/lib/skytab_sandbox_simulator/services/skytab/catalog_service.rb +130 -0
- data/lib/skytab_sandbox_simulator/services/skytab/location_service.rb +54 -0
- data/lib/skytab_sandbox_simulator/services/skytab/order_service.rb +139 -0
- data/lib/skytab_sandbox_simulator/services/skytab/payment_service.rb +94 -0
- data/lib/skytab_sandbox_simulator/services/skytab/service_manager.rb +62 -0
- data/lib/skytab_sandbox_simulator/version.rb +5 -0
- data/lib/skytab_sandbox_simulator.rb +45 -0
- 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
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
|
+
}
|