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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +176 -0
- data/bin/simulate +388 -0
- data/lib/square_sandbox_simulator/configuration.rb +193 -0
- data/lib/square_sandbox_simulator/data/cafe_bakery/categories.json +54 -0
- data/lib/square_sandbox_simulator/data/cafe_bakery/combos.json +33 -0
- data/lib/square_sandbox_simulator/data/cafe_bakery/coupon_codes.json +133 -0
- data/lib/square_sandbox_simulator/data/cafe_bakery/discounts.json +113 -0
- data/lib/square_sandbox_simulator/data/cafe_bakery/items.json +55 -0
- data/lib/square_sandbox_simulator/data/cafe_bakery/modifiers.json +73 -0
- data/lib/square_sandbox_simulator/data/cafe_bakery/tax_rates.json +26 -0
- data/lib/square_sandbox_simulator/data/cafe_bakery/tenders.json +41 -0
- data/lib/square_sandbox_simulator/data/restaurant/categories.json +54 -0
- data/lib/square_sandbox_simulator/data/restaurant/combos.json +265 -0
- data/lib/square_sandbox_simulator/data/restaurant/coupon_codes.json +266 -0
- data/lib/square_sandbox_simulator/data/restaurant/discounts.json +198 -0
- data/lib/square_sandbox_simulator/data/restaurant/gift_cards.json +82 -0
- data/lib/square_sandbox_simulator/data/restaurant/items.json +388 -0
- data/lib/square_sandbox_simulator/data/restaurant/modifiers.json +62 -0
- data/lib/square_sandbox_simulator/data/restaurant/tax_rates.json +38 -0
- data/lib/square_sandbox_simulator/data/restaurant/tenders.json +41 -0
- data/lib/square_sandbox_simulator/data/salon_spa/categories.json +24 -0
- data/lib/square_sandbox_simulator/data/salon_spa/combos.json +88 -0
- data/lib/square_sandbox_simulator/data/salon_spa/coupon_codes.json +96 -0
- data/lib/square_sandbox_simulator/data/salon_spa/discounts.json +93 -0
- data/lib/square_sandbox_simulator/data/salon_spa/gift_cards.json +47 -0
- data/lib/square_sandbox_simulator/data/salon_spa/items.json +100 -0
- data/lib/square_sandbox_simulator/data/salon_spa/modifiers.json +49 -0
- data/lib/square_sandbox_simulator/data/salon_spa/tax_rates.json +17 -0
- data/lib/square_sandbox_simulator/data/salon_spa/tenders.json +41 -0
- data/lib/square_sandbox_simulator/database.rb +224 -0
- data/lib/square_sandbox_simulator/db/factories/api_requests.rb +95 -0
- data/lib/square_sandbox_simulator/db/factories/business_types.rb +178 -0
- data/lib/square_sandbox_simulator/db/factories/categories.rb +379 -0
- data/lib/square_sandbox_simulator/db/factories/daily_summaries.rb +56 -0
- data/lib/square_sandbox_simulator/db/factories/items.rb +1526 -0
- data/lib/square_sandbox_simulator/db/factories/simulated_orders.rb +112 -0
- data/lib/square_sandbox_simulator/db/factories/simulated_payments.rb +61 -0
- data/lib/square_sandbox_simulator/db/migrate/20260312000000_enable_pgcrypto.rb +7 -0
- data/lib/square_sandbox_simulator/db/migrate/20260312000001_create_business_types.rb +18 -0
- data/lib/square_sandbox_simulator/db/migrate/20260312000002_create_categories.rb +18 -0
- data/lib/square_sandbox_simulator/db/migrate/20260312000003_create_items.rb +23 -0
- data/lib/square_sandbox_simulator/db/migrate/20260312000004_create_simulated_orders.rb +36 -0
- data/lib/square_sandbox_simulator/db/migrate/20260312000005_create_simulated_payments.rb +26 -0
- data/lib/square_sandbox_simulator/db/migrate/20260312000006_create_api_requests.rb +27 -0
- data/lib/square_sandbox_simulator/db/migrate/20260312000007_create_daily_summaries.rb +24 -0
- data/lib/square_sandbox_simulator/generators/data_loader.rb +202 -0
- data/lib/square_sandbox_simulator/generators/entity_generator.rb +248 -0
- data/lib/square_sandbox_simulator/generators/order_generator.rb +632 -0
- data/lib/square_sandbox_simulator/models/api_request.rb +43 -0
- data/lib/square_sandbox_simulator/models/business_type.rb +25 -0
- data/lib/square_sandbox_simulator/models/category.rb +18 -0
- data/lib/square_sandbox_simulator/models/daily_summary.rb +68 -0
- data/lib/square_sandbox_simulator/models/item.rb +33 -0
- data/lib/square_sandbox_simulator/models/record.rb +16 -0
- data/lib/square_sandbox_simulator/models/simulated_order.rb +42 -0
- data/lib/square_sandbox_simulator/models/simulated_payment.rb +28 -0
- data/lib/square_sandbox_simulator/seeder.rb +242 -0
- data/lib/square_sandbox_simulator/services/base_service.rb +253 -0
- data/lib/square_sandbox_simulator/services/square/catalog_service.rb +203 -0
- data/lib/square_sandbox_simulator/services/square/customer_service.rb +130 -0
- data/lib/square_sandbox_simulator/services/square/order_service.rb +121 -0
- data/lib/square_sandbox_simulator/services/square/payment_service.rb +136 -0
- data/lib/square_sandbox_simulator/services/square/services_manager.rb +68 -0
- data/lib/square_sandbox_simulator/services/square/team_service.rb +108 -0
- data/lib/square_sandbox_simulator/version.rb +5 -0
- data/lib/square_sandbox_simulator.rb +47 -0
- 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
|