square_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 (69) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +176 -0
  4. data/bin/simulate +388 -0
  5. data/lib/square_sandbox_simulator/configuration.rb +193 -0
  6. data/lib/square_sandbox_simulator/data/cafe_bakery/categories.json +54 -0
  7. data/lib/square_sandbox_simulator/data/cafe_bakery/combos.json +33 -0
  8. data/lib/square_sandbox_simulator/data/cafe_bakery/coupon_codes.json +133 -0
  9. data/lib/square_sandbox_simulator/data/cafe_bakery/discounts.json +113 -0
  10. data/lib/square_sandbox_simulator/data/cafe_bakery/items.json +55 -0
  11. data/lib/square_sandbox_simulator/data/cafe_bakery/modifiers.json +73 -0
  12. data/lib/square_sandbox_simulator/data/cafe_bakery/tax_rates.json +26 -0
  13. data/lib/square_sandbox_simulator/data/cafe_bakery/tenders.json +41 -0
  14. data/lib/square_sandbox_simulator/data/restaurant/categories.json +54 -0
  15. data/lib/square_sandbox_simulator/data/restaurant/combos.json +265 -0
  16. data/lib/square_sandbox_simulator/data/restaurant/coupon_codes.json +266 -0
  17. data/lib/square_sandbox_simulator/data/restaurant/discounts.json +198 -0
  18. data/lib/square_sandbox_simulator/data/restaurant/gift_cards.json +82 -0
  19. data/lib/square_sandbox_simulator/data/restaurant/items.json +388 -0
  20. data/lib/square_sandbox_simulator/data/restaurant/modifiers.json +62 -0
  21. data/lib/square_sandbox_simulator/data/restaurant/tax_rates.json +38 -0
  22. data/lib/square_sandbox_simulator/data/restaurant/tenders.json +41 -0
  23. data/lib/square_sandbox_simulator/data/salon_spa/categories.json +24 -0
  24. data/lib/square_sandbox_simulator/data/salon_spa/combos.json +88 -0
  25. data/lib/square_sandbox_simulator/data/salon_spa/coupon_codes.json +96 -0
  26. data/lib/square_sandbox_simulator/data/salon_spa/discounts.json +93 -0
  27. data/lib/square_sandbox_simulator/data/salon_spa/gift_cards.json +47 -0
  28. data/lib/square_sandbox_simulator/data/salon_spa/items.json +100 -0
  29. data/lib/square_sandbox_simulator/data/salon_spa/modifiers.json +49 -0
  30. data/lib/square_sandbox_simulator/data/salon_spa/tax_rates.json +17 -0
  31. data/lib/square_sandbox_simulator/data/salon_spa/tenders.json +41 -0
  32. data/lib/square_sandbox_simulator/database.rb +224 -0
  33. data/lib/square_sandbox_simulator/db/factories/api_requests.rb +95 -0
  34. data/lib/square_sandbox_simulator/db/factories/business_types.rb +178 -0
  35. data/lib/square_sandbox_simulator/db/factories/categories.rb +379 -0
  36. data/lib/square_sandbox_simulator/db/factories/daily_summaries.rb +56 -0
  37. data/lib/square_sandbox_simulator/db/factories/items.rb +1526 -0
  38. data/lib/square_sandbox_simulator/db/factories/simulated_orders.rb +112 -0
  39. data/lib/square_sandbox_simulator/db/factories/simulated_payments.rb +61 -0
  40. data/lib/square_sandbox_simulator/db/migrate/20260312000000_enable_pgcrypto.rb +7 -0
  41. data/lib/square_sandbox_simulator/db/migrate/20260312000001_create_business_types.rb +18 -0
  42. data/lib/square_sandbox_simulator/db/migrate/20260312000002_create_categories.rb +18 -0
  43. data/lib/square_sandbox_simulator/db/migrate/20260312000003_create_items.rb +23 -0
  44. data/lib/square_sandbox_simulator/db/migrate/20260312000004_create_simulated_orders.rb +36 -0
  45. data/lib/square_sandbox_simulator/db/migrate/20260312000005_create_simulated_payments.rb +26 -0
  46. data/lib/square_sandbox_simulator/db/migrate/20260312000006_create_api_requests.rb +27 -0
  47. data/lib/square_sandbox_simulator/db/migrate/20260312000007_create_daily_summaries.rb +24 -0
  48. data/lib/square_sandbox_simulator/generators/data_loader.rb +202 -0
  49. data/lib/square_sandbox_simulator/generators/entity_generator.rb +248 -0
  50. data/lib/square_sandbox_simulator/generators/order_generator.rb +632 -0
  51. data/lib/square_sandbox_simulator/models/api_request.rb +43 -0
  52. data/lib/square_sandbox_simulator/models/business_type.rb +25 -0
  53. data/lib/square_sandbox_simulator/models/category.rb +18 -0
  54. data/lib/square_sandbox_simulator/models/daily_summary.rb +68 -0
  55. data/lib/square_sandbox_simulator/models/item.rb +33 -0
  56. data/lib/square_sandbox_simulator/models/record.rb +16 -0
  57. data/lib/square_sandbox_simulator/models/simulated_order.rb +42 -0
  58. data/lib/square_sandbox_simulator/models/simulated_payment.rb +28 -0
  59. data/lib/square_sandbox_simulator/seeder.rb +242 -0
  60. data/lib/square_sandbox_simulator/services/base_service.rb +253 -0
  61. data/lib/square_sandbox_simulator/services/square/catalog_service.rb +203 -0
  62. data/lib/square_sandbox_simulator/services/square/customer_service.rb +130 -0
  63. data/lib/square_sandbox_simulator/services/square/order_service.rb +121 -0
  64. data/lib/square_sandbox_simulator/services/square/payment_service.rb +136 -0
  65. data/lib/square_sandbox_simulator/services/square/services_manager.rb +68 -0
  66. data/lib/square_sandbox_simulator/services/square/team_service.rb +108 -0
  67. data/lib/square_sandbox_simulator/version.rb +5 -0
  68. data/lib/square_sandbox_simulator.rb +47 -0
  69. metadata +348 -0
@@ -0,0 +1,632 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SquareSandboxSimulator
4
+ module Generators
5
+ # Generates realistic orders and payments against the Square sandbox API.
6
+ #
7
+ # Phase 1 scope: line items (via catalog variations), discounts, taxes,
8
+ # card payments, cash payments, tips, and refunds.
9
+ # Skipped for Phase 1: gift cards, cash events, modifier groups.
10
+ class OrderGenerator
11
+ # Meal periods with realistic distributions
12
+ MEAL_PERIODS = {
13
+ breakfast: { hours: 7..10, weight: 15, avg_items: 3..6, avg_party: 1..3 },
14
+ lunch: { hours: 11..14, weight: 30, avg_items: 3..7, avg_party: 1..4 },
15
+ happy_hour: { hours: 15..17, weight: 10, avg_items: 3..6, avg_party: 2..5 },
16
+ dinner: { hours: 17..21, weight: 35, avg_items: 4..9, avg_party: 2..6 },
17
+ late_night: { hours: 21..23, weight: 10, avg_items: 3..6, avg_party: 1..3 },
18
+ }.freeze
19
+
20
+ # Dining option distributions by meal period
21
+ DINING_BY_PERIOD = {
22
+ breakfast: { "HERE" => 40, "TO_GO" => 50, "DELIVERY" => 10 },
23
+ lunch: { "HERE" => 35, "TO_GO" => 45, "DELIVERY" => 20 },
24
+ happy_hour: { "HERE" => 80, "TO_GO" => 15, "DELIVERY" => 5 },
25
+ dinner: { "HERE" => 70, "TO_GO" => 15, "DELIVERY" => 15 },
26
+ late_night: { "HERE" => 50, "TO_GO" => 30, "DELIVERY" => 20 },
27
+ }.freeze
28
+
29
+ # Tip percentages by dining option
30
+ TIP_RATES = {
31
+ "HERE" => { min: 15, max: 25 },
32
+ "TO_GO" => { min: 0, max: 15 },
33
+ "DELIVERY" => { min: 10, max: 20 },
34
+ }.freeze
35
+
36
+ # Order patterns by day of week
37
+ ORDER_PATTERNS = {
38
+ weekday: { min: 40, max: 60 },
39
+ friday: { min: 70, max: 100 },
40
+ saturday: { min: 80, max: 120 },
41
+ sunday: { min: 50, max: 80 },
42
+ }.freeze
43
+
44
+ # Category preferences by meal period
45
+ CATEGORY_PREFERENCES = {
46
+ breakfast: ["Brunch", "Drinks", "Sides", "Pastries", "Breakfast", "Coffee & Espresso"],
47
+ lunch: ["Appetizers", "Soups & Salads", "Entrees", "Sides", "Drinks", "Sandwiches"],
48
+ happy_hour: ["Appetizers", "Cocktails", "Draft Beer", "Wine", "Bar Snacks", "Drinks"],
49
+ dinner: %w[Appetizers Entrees Sides Desserts Drinks],
50
+ late_night: ["Appetizers", "Entrees", "Desserts", "Drinks", "Bar Snacks"],
51
+ }.freeze
52
+
53
+ # Discount application probabilities
54
+ DISCOUNT_PROBABILITIES = {
55
+ order_discount: 0.12,
56
+ line_item_discount: 0.08,
57
+ }.freeze
58
+
59
+ # Card vs cash payment split
60
+ CARD_PAYMENT_CHANCE = 75 # 75% card, 25% cash
61
+
62
+ # Refund reasons
63
+ REFUND_REASONS = %w[customer_request quality_issue wrong_order duplicate_charge].freeze
64
+
65
+ attr_reader :services, :logger, :stats, :refund_percentage
66
+
67
+ def initialize(services: nil, refund_percentage: 5)
68
+ @services = services || Services::Square::ServicesManager.new
69
+ @logger = SquareSandboxSimulator.logger
70
+ @refund_percentage = refund_percentage
71
+ @stats = {
72
+ orders: 0,
73
+ revenue: 0,
74
+ tips: 0,
75
+ tax: 0,
76
+ discounts: 0,
77
+ by_period: {},
78
+ by_dining: {},
79
+ refunds: { total: 0, full: 0, partial: 0, amount: 0 },
80
+ cash_payments: 0,
81
+ card_payments: 0,
82
+ }
83
+ end
84
+
85
+ # Generate a realistic day of operations.
86
+ # @param date [Date] Date to generate orders for (defaults to today in merchant timezone)
87
+ # @param multiplier [Float] Multiplier for order count (0.5 = slow day, 2.0 = busy day)
88
+ def generate_realistic_day(date: nil, multiplier: 1.0)
89
+ date ||= SquareSandboxSimulator.configuration.merchant_date_today
90
+ count = (order_count_for_date(date) * multiplier).to_i
91
+
92
+ logger.info "=" * 60
93
+ logger.info "Generating realistic day: #{date}"
94
+ logger.info " Target orders: #{count}"
95
+ logger.info " Day: #{date.strftime("%A")}"
96
+ logger.info "=" * 60
97
+
98
+ data = fetch_required_data
99
+ return [] unless data
100
+
101
+ # Distribute orders across meal periods
102
+ period_orders = distribute_orders_by_period(count)
103
+
104
+ orders = []
105
+ period_orders.each do |period, period_count|
106
+ logger.info "-" * 40
107
+ logger.info "#{period.to_s.upcase} SERVICE: #{period_count} orders"
108
+
109
+ period_count.times do |i|
110
+ order_time = generate_order_time(date, period)
111
+
112
+ order = create_realistic_order(
113
+ period: period,
114
+ data: data,
115
+ order_num: i + 1,
116
+ total_in_period: period_count,
117
+ order_time: order_time,
118
+ )
119
+
120
+ if order
121
+ orders << order
122
+ update_stats(order, period)
123
+ end
124
+ end
125
+ end
126
+
127
+ # Process refunds for some orders
128
+ process_refunds(orders) if refund_percentage.positive?
129
+
130
+ # Generate daily summary for audit trail
131
+ generate_daily_summary(date)
132
+
133
+ print_summary
134
+ orders
135
+ end
136
+
137
+ # Generate orders for today (simple mode).
138
+ # Uses merchant timezone for "today".
139
+ def generate_today(count: nil)
140
+ today = SquareSandboxSimulator.configuration.merchant_date_today
141
+ if count
142
+ generate_for_date(today, count: count)
143
+ else
144
+ generate_realistic_day(date: today)
145
+ end
146
+ end
147
+
148
+ # Generate specific count of orders for a date.
149
+ def generate_for_date(date, count:)
150
+ logger.info "=" * 60
151
+ logger.info "Generating #{count} orders for #{date}"
152
+ logger.info "=" * 60
153
+
154
+ data = fetch_required_data
155
+ return [] unless data
156
+
157
+ orders = []
158
+ count.times do |i|
159
+ period = weighted_random_period
160
+ order_time = generate_order_time(date, period)
161
+ logger.info "-" * 40
162
+ logger.info "Creating order #{i + 1}/#{count} (#{period})"
163
+
164
+ order = create_realistic_order(
165
+ period: period,
166
+ data: data,
167
+ order_num: i + 1,
168
+ total_in_period: count,
169
+ order_time: order_time,
170
+ )
171
+
172
+ if order
173
+ orders << order
174
+ update_stats(order, period)
175
+ end
176
+ end
177
+
178
+ # Process refunds for some orders
179
+ process_refunds(orders) if refund_percentage.positive?
180
+
181
+ # Generate daily summary for audit trail
182
+ generate_daily_summary(date)
183
+
184
+ print_summary
185
+ orders
186
+ end
187
+
188
+ # Process refunds for a percentage of completed orders.
189
+ def process_refunds(orders)
190
+ return if orders.empty? || refund_percentage <= 0
191
+
192
+ refund_count = (orders.size * refund_percentage / 100.0).ceil
193
+ refund_count = [refund_count, orders.size].min
194
+
195
+ logger.info "-" * 40
196
+ logger.info "PROCESSING REFUNDS: #{refund_count} orders (#{refund_percentage}%)"
197
+
198
+ orders_to_refund = orders.sample(refund_count)
199
+
200
+ orders_to_refund.each do |order|
201
+ process_order_refund(order)
202
+ end
203
+ end
204
+
205
+ private
206
+
207
+ # Fetch all data needed for order generation from Square API.
208
+ def fetch_required_data
209
+ items = services.catalog.list_catalog(types: "ITEM")
210
+ customers = services.customer.list_customers
211
+ discounts = services.catalog.list_catalog(types: "DISCOUNT")
212
+ taxes = services.catalog.list_catalog(types: "TAX")
213
+
214
+ if items.empty?
215
+ logger.error "No items found! Please run setup first."
216
+ return nil
217
+ end
218
+
219
+ # Extract variation info for each item (orders need variation IDs)
220
+ variations = EntityGenerator.extract_variation_ids(items)
221
+
222
+ if variations.empty?
223
+ logger.error "No item variations found! Items may be misconfigured."
224
+ return nil
225
+ end
226
+
227
+ # Group items by category name for meal period selection
228
+ items_by_category = items.group_by do |item|
229
+ # Square items may have category_id; try to resolve name
230
+ item.dig("item_data", "category_id") || "Other"
231
+ end
232
+
233
+ {
234
+ items: items,
235
+ variations: variations,
236
+ items_by_category: items_by_category,
237
+ customers: customers,
238
+ discounts: discounts,
239
+ taxes: taxes,
240
+ }
241
+ end
242
+
243
+ # Create a single realistic order with line items, payment, tip, and optional discount.
244
+ def create_realistic_order(period:, data:, order_num:, total_in_period:, order_time: nil)
245
+ order_time ||= Time.now
246
+ config = MEAL_PERIODS[period]
247
+
248
+ # 60% of orders have customer info
249
+ customer = data[:customers].sample if data[:customers].any? && rand < 0.6
250
+
251
+ # Party size affects item count
252
+ party_size = rand(config[:avg_party])
253
+ base_items = rand(config[:avg_items])
254
+ num_items = [base_items + (party_size / 2), 1].max
255
+
256
+ # Select random variations for line items
257
+ selected = data[:variations].sample([num_items, data[:variations].size].min)
258
+
259
+ # Build line items payload for Square Orders API
260
+ line_items = selected.map do |var|
261
+ quantity = party_size > 2 && rand < 0.3 ? rand(2..3) : 1
262
+
263
+ {
264
+ "catalog_object_id" => var[:variation_id],
265
+ "quantity" => quantity.to_s,
266
+ }
267
+ end
268
+
269
+ # Build order-level taxes (apply first tax to all items)
270
+ order_taxes = []
271
+ if data[:taxes].any?
272
+ tax = data[:taxes].first
273
+ uid = "tax_#{SecureRandom.hex(4)}"
274
+ order_taxes << {
275
+ "uid" => uid,
276
+ "catalog_object_id" => tax["id"],
277
+ "scope" => "ORDER",
278
+ }
279
+ end
280
+
281
+ # Build order-level discounts (probabilistic)
282
+ order_discounts = []
283
+ if data[:discounts].any? && rand < DISCOUNT_PROBABILITIES[:order_discount]
284
+ disc = data[:discounts].sample
285
+ disc["discount_data"] || {}
286
+
287
+ discount_entry = {
288
+ "uid" => "disc_#{SecureRandom.hex(4)}",
289
+ "catalog_object_id" => disc["id"],
290
+ "scope" => "ORDER",
291
+ }
292
+ order_discounts << discount_entry
293
+ end
294
+
295
+ # Create the order (all line items at once)
296
+ order = services.order.create_order(
297
+ line_items: line_items,
298
+ discounts: order_discounts,
299
+ taxes: order_taxes,
300
+ customer_id: customer&.dig("id"),
301
+ )
302
+
303
+ return nil unless order && order["id"]
304
+
305
+ order_id = order["id"]
306
+ logger.info " Order #{order_num}/#{total_in_period}: #{order_id} (#{line_items.size} items)"
307
+
308
+ # Determine dining option and calculate tip
309
+ dining = select_dining_option(period)
310
+ subtotal = order.dig("total_money", "amount") || 0
311
+ tip_amount = calculate_tip(subtotal, dining, party_size)
312
+
313
+ # Process payment (card or cash)
314
+ payment = if rand(100) < CARD_PAYMENT_CHANCE
315
+ # Card payment with tip
316
+ services.payment.create_payment(
317
+ order_id: order_id,
318
+ amount: subtotal,
319
+ source_id: "cnon:card-nonce-ok",
320
+ tip_amount: tip_amount,
321
+ )
322
+ else
323
+ # Cash payment (no tip tracking for cash)
324
+ services.payment.create_cash_payment(
325
+ order_id: order_id,
326
+ amount: subtotal,
327
+ )
328
+ end
329
+
330
+ unless payment
331
+ logger.warn " Payment failed for order #{order_id}"
332
+ return nil
333
+ end
334
+
335
+ logger.info " Payment: #{payment["id"]} ($#{format("%.2f", subtotal / 100.0)}" \
336
+ "#{" + $#{format("%.2f", tip_amount / 100.0)} tip" if tip_amount.positive?})"
337
+
338
+ # Attach metadata for tracking
339
+ order["_metadata"] = {
340
+ period: period,
341
+ dining: dining,
342
+ party_size: party_size,
343
+ tip: tip_amount,
344
+ order_time: order_time,
345
+ payment_id: payment["id"],
346
+ payment_type: payment["source_type"] || (payment.dig("external_details", "type") == "CASH" ? "CASH" : "CARD"),
347
+ }
348
+
349
+ # Track in audit DB
350
+ track_simulated_order(order, period: period, dining: dining, date: order_time.to_date)
351
+
352
+ order
353
+ end
354
+
355
+ # Select a dining option based on meal period weights.
356
+ def select_dining_option(period)
357
+ options = DINING_BY_PERIOD[period]
358
+ total = options.values.sum
359
+ random = rand(total)
360
+
361
+ cumulative = 0
362
+ options.each do |option, weight|
363
+ cumulative += weight
364
+ return option if random < cumulative
365
+ end
366
+
367
+ "HERE"
368
+ end
369
+
370
+ # Calculate tip based on subtotal, dining option, and party size.
371
+ def calculate_tip(subtotal, dining, party_size)
372
+ rates = TIP_RATES[dining] || TIP_RATES["HERE"]
373
+
374
+ # 20% of orders leave no tip
375
+ return 0 if rand < 0.20
376
+
377
+ # Large parties tend to tip slightly higher
378
+ min_rate = rates[:min]
379
+ max_rate = rates[:max]
380
+ max_rate += 3 if party_size >= 4
381
+
382
+ tip_pct = rand(min_rate..max_rate)
383
+ (subtotal * tip_pct / 100.0).round
384
+ end
385
+
386
+ # Process a refund for a single order.
387
+ def process_order_refund(order)
388
+ order_id = order["id"]
389
+ payment_id = order.dig("_metadata", :payment_id)
390
+ payment_type = order.dig("_metadata", :payment_type)
391
+
392
+ # Cannot refund cash payments via API
393
+ return if payment_type == "CASH"
394
+ return unless payment_id
395
+
396
+ # Get payment amount
397
+ payment = services.payment.get_payment(payment_id)
398
+ return unless payment
399
+
400
+ payment_amount = payment.dig("amount_money", "amount") || 0
401
+ return if payment_amount <= 0
402
+
403
+ # 60% full refunds, 40% partial
404
+ is_full_refund = rand < 0.6
405
+ reason = REFUND_REASONS.sample
406
+
407
+ if is_full_refund
408
+ begin
409
+ result = services.payment.refund_payment(
410
+ payment_id: payment_id,
411
+ amount: payment_amount,
412
+ reason: reason,
413
+ )
414
+ if result
415
+ @stats[:refunds][:total] += 1
416
+ @stats[:refunds][:full] += 1
417
+ @stats[:refunds][:amount] += payment_amount
418
+ logger.info " Full refund: Order #{order_id} - $#{format("%.2f", payment_amount / 100.0)} (#{reason})"
419
+ track_refund(order_id)
420
+ end
421
+ rescue StandardError => e
422
+ logger.warn " Failed to refund order #{order_id}: #{e.message}"
423
+ end
424
+ else
425
+ refund_percent = rand(25..75)
426
+ refund_amount = (payment_amount * refund_percent / 100.0).round
427
+
428
+ begin
429
+ result = services.payment.refund_payment(
430
+ payment_id: payment_id,
431
+ amount: refund_amount,
432
+ reason: reason,
433
+ )
434
+ if result
435
+ @stats[:refunds][:total] += 1
436
+ @stats[:refunds][:partial] += 1
437
+ @stats[:refunds][:amount] += refund_amount
438
+ logger.info " Partial refund: Order #{order_id} - $#{format("%.2f", refund_amount / 100.0)} " \
439
+ "of $#{format("%.2f", payment_amount / 100.0)} (#{reason})"
440
+ track_refund(order_id)
441
+ end
442
+ rescue StandardError => e
443
+ logger.warn " Failed to partially refund order #{order_id}: #{e.message}"
444
+ end
445
+ end
446
+ end
447
+
448
+ # -- Distribution helpers --
449
+
450
+ def order_count_for_date(date)
451
+ day = date.strftime("%A").downcase.to_sym
452
+
453
+ pattern = case day
454
+ when :friday then ORDER_PATTERNS[:friday]
455
+ when :saturday then ORDER_PATTERNS[:saturday]
456
+ when :sunday then ORDER_PATTERNS[:sunday]
457
+ else ORDER_PATTERNS[:weekday]
458
+ end
459
+
460
+ rand(pattern[:min]..pattern[:max])
461
+ end
462
+
463
+ def distribute_orders_by_period(total_count)
464
+ total_weight = MEAL_PERIODS.values.sum { |p| p[:weight] }
465
+
466
+ distribution = {}
467
+ remaining = total_count
468
+
469
+ MEAL_PERIODS.each_with_index do |(period, cfg), index|
470
+ if index == MEAL_PERIODS.size - 1
471
+ distribution[period] = remaining
472
+ else
473
+ count = ((cfg[:weight].to_f / total_weight) * total_count).round
474
+ distribution[period] = [count, remaining].min
475
+ remaining -= distribution[period]
476
+ end
477
+ end
478
+
479
+ distribution
480
+ end
481
+
482
+ def weighted_random_period
483
+ total_weight = MEAL_PERIODS.values.sum { |p| p[:weight] }
484
+ random = rand(total_weight)
485
+
486
+ cumulative = 0
487
+ MEAL_PERIODS.each do |period, cfg|
488
+ cumulative += cfg[:weight]
489
+ return period if random < cumulative
490
+ end
491
+
492
+ :dinner
493
+ end
494
+
495
+ def generate_order_time(date, period)
496
+ hours = MEAL_PERIODS[period][:hours]
497
+ hour = rand(hours)
498
+ minute = rand(60)
499
+
500
+ tz_identifier = SquareSandboxSimulator.configuration.fetch_location_timezone
501
+ begin
502
+ require "tzinfo"
503
+ tz = TZInfo::Timezone.get(tz_identifier)
504
+ tz.local_time(date.year, date.month, date.day, hour, minute, 0)
505
+ rescue LoadError
506
+ Time.new(date.year, date.month, date.day, hour, minute, 0)
507
+ end
508
+ end
509
+
510
+ # -- Stats tracking --
511
+
512
+ def update_stats(order, period)
513
+ total = order.dig("total_money", "amount") || 0
514
+ tip = order.dig("_metadata", :tip) || 0
515
+ tax = order.dig("total_tax_money", "amount") || 0
516
+ dining = order.dig("_metadata", :dining) || "HERE"
517
+ payment_type = order.dig("_metadata", :payment_type) || "CARD"
518
+
519
+ @stats[:orders] += 1
520
+ @stats[:revenue] += total
521
+ @stats[:tips] += tip
522
+ @stats[:tax] += tax
523
+
524
+ @stats[:by_period][period] = (@stats[:by_period][period] || 0) + 1
525
+ @stats[:by_dining][dining] = (@stats[:by_dining][dining] || 0) + 1
526
+
527
+ if payment_type == "CASH"
528
+ @stats[:cash_payments] += 1
529
+ else
530
+ @stats[:card_payments] += 1
531
+ end
532
+ end
533
+
534
+ def print_summary
535
+ logger.info "=" * 60
536
+ logger.info "ORDER GENERATION SUMMARY"
537
+ logger.info "=" * 60
538
+ logger.info " Total Orders: #{@stats[:orders]}"
539
+ logger.info " Revenue: $#{format("%.2f", @stats[:revenue] / 100.0)}"
540
+ logger.info " Tips: $#{format("%.2f", @stats[:tips] / 100.0)}"
541
+ logger.info " Tax: $#{format("%.2f", @stats[:tax] / 100.0)}"
542
+ logger.info " Card Payments: #{@stats[:card_payments]}"
543
+ logger.info " Cash Payments: #{@stats[:cash_payments]}"
544
+
545
+ if @stats[:by_period].any?
546
+ logger.info ""
547
+ logger.info " By Period:"
548
+ @stats[:by_period].each do |period, count|
549
+ logger.info " #{period.to_s.ljust(15)} #{count}"
550
+ end
551
+ end
552
+
553
+ if @stats[:by_dining].any?
554
+ logger.info ""
555
+ logger.info " By Dining:"
556
+ @stats[:by_dining].each do |option, count|
557
+ logger.info " #{option.ljust(15)} #{count}"
558
+ end
559
+ end
560
+
561
+ if @stats[:refunds][:total].positive?
562
+ logger.info ""
563
+ logger.info " Refunds: #{@stats[:refunds][:total]} " \
564
+ "(#{@stats[:refunds][:full]} full, #{@stats[:refunds][:partial]} partial) " \
565
+ "$#{format("%.2f", @stats[:refunds][:amount] / 100.0)}"
566
+ end
567
+
568
+ logger.info "=" * 60
569
+ end
570
+
571
+ # -- Audit DB persistence --
572
+
573
+ def track_simulated_order(order, period:, dining:, date:)
574
+ return unless Database.connected?
575
+
576
+ total = order.dig("total_money", "amount") || 0
577
+ tax = order.dig("total_tax_money", "amount") || 0
578
+ tip = order.dig("_metadata", :tip) || 0
579
+ discount = order.dig("total_discount_money", "amount") || 0
580
+
581
+ sim_order = Models::SimulatedOrder.create!(
582
+ square_order_id: order["id"],
583
+ square_location_id: SquareSandboxSimulator.configuration.location_id,
584
+ status: "paid",
585
+ business_date: date,
586
+ meal_period: period.to_s,
587
+ dining_option: dining,
588
+ total: total,
589
+ subtotal: total - tax,
590
+ tax_amount: tax,
591
+ tip_amount: tip,
592
+ discount_amount: discount,
593
+ metadata: order["_metadata"],
594
+ )
595
+
596
+ # Track payment
597
+ payment_id = order.dig("_metadata", :payment_id)
598
+ payment_type = order.dig("_metadata", :payment_type) || "CARD"
599
+
600
+ if payment_id
601
+ Models::SimulatedPayment.create!(
602
+ simulated_order: sim_order,
603
+ square_payment_id: payment_id,
604
+ tender_name: payment_type == "CASH" ? "Cash" : "Card",
605
+ status: "SUCCESS",
606
+ amount: total + tip,
607
+ )
608
+ end
609
+ rescue StandardError => e
610
+ logger.debug "Audit logging failed: #{e.message}"
611
+ end
612
+
613
+ def track_refund(order_id)
614
+ return unless Database.connected?
615
+
616
+ sim_order = Models::SimulatedOrder.find_by(square_order_id: order_id)
617
+ sim_order&.update!(status: "refunded")
618
+ rescue StandardError => e
619
+ logger.debug "Refund tracking failed: #{e.message}"
620
+ end
621
+
622
+ def generate_daily_summary(date)
623
+ return unless Database.connected?
624
+
625
+ location_id = SquareSandboxSimulator.configuration.location_id
626
+ Models::DailySummary.generate_for!(location_id, date)
627
+ rescue StandardError => e
628
+ logger.debug "Daily summary generation failed: #{e.message}"
629
+ end
630
+ end
631
+ end
632
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SquareSandboxSimulator
4
+ module Models
5
+ class ApiRequest < Record
6
+ # Validations
7
+ validates :http_method, presence: true
8
+ validates :url, presence: true
9
+
10
+ # Time scopes — use Time.now.utc for consistency with AR's UTC storage
11
+ scope :today, -> { where("created_at >= ?", Time.now.utc.beginning_of_day) }
12
+ scope :recent, ->(minutes = 60) { where("created_at >= ?", minutes.minutes.ago) }
13
+
14
+ # Status scopes
15
+ scope :errors, -> { where("response_status >= 400 OR error_message IS NOT NULL") }
16
+ scope :successful, -> { where("response_status < 400 AND error_message IS NULL") }
17
+
18
+ # Resource scopes
19
+ scope :for_resource, ->(type) { where(resource_type: type) }
20
+ scope :for_resource_id, ->(type, id) { where(resource_type: type, resource_id: id) }
21
+
22
+ # Location scope — matches /locations/<id>/ or /locations/<id> (end of URL)
23
+ scope :for_location, lambda { |location_id|
24
+ sanitized = sanitize_sql_like(location_id)
25
+ where("url LIKE ?", "%/locations/#{sanitized}/%")
26
+ .or(where("url LIKE ?", "%/locations/#{sanitized}"))
27
+ }
28
+
29
+ # HTTP method scopes
30
+ scope :gets, -> { where(http_method: "GET") }
31
+ scope :posts, -> { where(http_method: "POST") }
32
+ scope :puts, -> { where(http_method: "PUT") }
33
+ scope :deletes, -> { where(http_method: "DELETE") }
34
+
35
+ # Performance
36
+ scope :slow, ->(threshold_ms = 1000) { where("duration_ms > ?", threshold_ms) }
37
+
38
+ def error?
39
+ error_message.present? || (response_status && response_status >= 400)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SquareSandboxSimulator
4
+ module Models
5
+ class BusinessType < Record
6
+ has_many :categories, dependent: :destroy
7
+ has_many :items, through: :categories
8
+ has_many :simulated_orders, dependent: :nullify
9
+
10
+ # Validations
11
+ validates :key, presence: true, uniqueness: true
12
+ validates :name, presence: true
13
+
14
+ # Scopes by industry
15
+ scope :food_types, -> { where(industry: "food") }
16
+ scope :retail_types, -> { where(industry: "retail") }
17
+ scope :service_types, -> { where(industry: "service") }
18
+
19
+ # Find by key (the primary lookup pattern)
20
+ def self.find_by_key!(key)
21
+ find_by!(key: key)
22
+ end
23
+ end
24
+ end
25
+ end