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,253 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SquareSandboxSimulator
|
|
4
|
+
module Services
|
|
5
|
+
# Base service for all Square API interactions
|
|
6
|
+
# Provides HTTP client, logging, idempotency, and error handling
|
|
7
|
+
class BaseService
|
|
8
|
+
attr_reader :config, :logger
|
|
9
|
+
|
|
10
|
+
def initialize(config: nil)
|
|
11
|
+
@config = config || SquareSandboxSimulator.configuration
|
|
12
|
+
@config.validate!
|
|
13
|
+
@logger = @config.logger
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
protected
|
|
17
|
+
|
|
18
|
+
# Make HTTP request to Square API
|
|
19
|
+
#
|
|
20
|
+
# Every call is audit-logged to the `api_requests` table when a
|
|
21
|
+
# database connection is available. If no DB is connected the
|
|
22
|
+
# request still executes normally -- audit logging is a no-op.
|
|
23
|
+
#
|
|
24
|
+
# @param method [Symbol] HTTP method (:get, :post, :put, :delete)
|
|
25
|
+
# @param path [String] API endpoint path (e.g. "catalog/list")
|
|
26
|
+
# @param payload [Hash, nil] Request body for POST/PUT
|
|
27
|
+
# @param params [Hash, nil] Query parameters
|
|
28
|
+
# @param resource_type [String, nil] Logical resource (e.g. "CatalogObject")
|
|
29
|
+
# @param resource_id [String, nil] Square resource ID
|
|
30
|
+
# @return [Hash, nil] Parsed JSON response
|
|
31
|
+
def request(method, path, payload: nil, params: nil, resource_type: nil, resource_id: nil)
|
|
32
|
+
url = build_url(path, params)
|
|
33
|
+
|
|
34
|
+
# Inject idempotency key for POST requests
|
|
35
|
+
payload = inject_idempotency_key(payload) if method == :post && payload.is_a?(Hash)
|
|
36
|
+
|
|
37
|
+
log_request(method, url, payload)
|
|
38
|
+
start_time = Time.now
|
|
39
|
+
|
|
40
|
+
response = execute_request(method, url, payload)
|
|
41
|
+
|
|
42
|
+
duration_ms = ((Time.now - start_time) * 1000).round
|
|
43
|
+
log_response(response, duration_ms)
|
|
44
|
+
|
|
45
|
+
parsed = parse_response(response)
|
|
46
|
+
|
|
47
|
+
audit_api_request(
|
|
48
|
+
http_method: method.to_s.upcase,
|
|
49
|
+
url: url,
|
|
50
|
+
request_payload: payload,
|
|
51
|
+
response_status: response.code,
|
|
52
|
+
response_payload: parsed,
|
|
53
|
+
duration_ms: duration_ms,
|
|
54
|
+
resource_type: resource_type,
|
|
55
|
+
resource_id: resource_id,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
parsed
|
|
59
|
+
rescue RestClient::ExceptionWithResponse => e
|
|
60
|
+
duration_ms = ((Time.now - start_time) * 1000).round if start_time
|
|
61
|
+
|
|
62
|
+
audit_api_request(
|
|
63
|
+
http_method: method.to_s.upcase,
|
|
64
|
+
url: url,
|
|
65
|
+
request_payload: payload,
|
|
66
|
+
response_status: e.http_code,
|
|
67
|
+
response_payload: begin
|
|
68
|
+
JSON.parse(e.response.body)
|
|
69
|
+
rescue StandardError
|
|
70
|
+
nil
|
|
71
|
+
end,
|
|
72
|
+
duration_ms: duration_ms,
|
|
73
|
+
error_message: "HTTP #{e.http_code}: #{e.message}",
|
|
74
|
+
resource_type: resource_type,
|
|
75
|
+
resource_id: resource_id,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
handle_api_error(e)
|
|
79
|
+
rescue StandardError => e
|
|
80
|
+
duration_ms = ((Time.now - start_time) * 1000).round if start_time
|
|
81
|
+
|
|
82
|
+
audit_api_request(
|
|
83
|
+
http_method: method.to_s.upcase,
|
|
84
|
+
url: url,
|
|
85
|
+
request_payload: payload,
|
|
86
|
+
duration_ms: duration_ms,
|
|
87
|
+
error_message: e.message,
|
|
88
|
+
resource_type: resource_type,
|
|
89
|
+
resource_id: resource_id,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
logger.error "Request failed: #{e.message}"
|
|
93
|
+
raise ApiError, e.message
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Build endpoint path for Square v2 API
|
|
97
|
+
#
|
|
98
|
+
# @param path [String] Relative path (e.g. "catalog/list")
|
|
99
|
+
# @return [String] Full endpoint path (e.g. "v2/catalog/list")
|
|
100
|
+
def endpoint(path)
|
|
101
|
+
"v2/#{path}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def headers
|
|
107
|
+
{
|
|
108
|
+
"Authorization" => "Bearer #{config.access_token}",
|
|
109
|
+
"Content-Type" => "application/json",
|
|
110
|
+
"Accept" => "application/json",
|
|
111
|
+
"Square-Version" => config.square_version,
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def build_url(path, params = nil)
|
|
116
|
+
base = path.start_with?("http") ? path : "#{config.environment}v2/#{path}"
|
|
117
|
+
return base unless params&.any?
|
|
118
|
+
|
|
119
|
+
uri = URI(base)
|
|
120
|
+
uri.query = URI.encode_www_form(params)
|
|
121
|
+
uri.to_s
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def execute_request(method, url, payload)
|
|
125
|
+
case method
|
|
126
|
+
when :get then RestClient.get(url, headers)
|
|
127
|
+
when :post then RestClient.post(url, payload&.to_json, headers)
|
|
128
|
+
when :put then RestClient.put(url, payload&.to_json, headers)
|
|
129
|
+
when :delete then RestClient.delete(url, headers)
|
|
130
|
+
else raise ArgumentError, "Unsupported HTTP method: #{method}"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def parse_response(response)
|
|
135
|
+
return nil if response.body.nil? || response.body.empty?
|
|
136
|
+
|
|
137
|
+
JSON.parse(response.body)
|
|
138
|
+
rescue JSON::ParserError => e
|
|
139
|
+
logger.error "Failed to parse response: #{e.message}"
|
|
140
|
+
raise ApiError, "Invalid JSON response"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Inject idempotency_key into POST payloads if not already present
|
|
144
|
+
def inject_idempotency_key(payload)
|
|
145
|
+
return payload unless payload.is_a?(Hash)
|
|
146
|
+
return payload if payload.key?("idempotency_key")
|
|
147
|
+
|
|
148
|
+
payload.merge("idempotency_key" => SecureRandom.uuid)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def handle_api_error(error)
|
|
152
|
+
body = begin
|
|
153
|
+
JSON.parse(error.response.body)
|
|
154
|
+
rescue StandardError
|
|
155
|
+
{ "errors" => [{ "detail" => error.response.body }] }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
errors = body["errors"] || []
|
|
159
|
+
detail = errors.map { |e| e["detail"] }.compact.join("; ")
|
|
160
|
+
detail = error.response.body if detail.empty?
|
|
161
|
+
|
|
162
|
+
logger.error "API Error (#{error.http_code}): #{body}"
|
|
163
|
+
raise ApiError, "HTTP #{error.http_code}: #{detail}"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def log_request(method, url, payload)
|
|
167
|
+
logger.debug "-> #{method.to_s.upcase} #{url}"
|
|
168
|
+
logger.debug " Payload: #{payload.inspect}" if payload
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def log_response(response, duration_ms)
|
|
172
|
+
logger.debug "<- #{response.code} (#{duration_ms}ms)"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Persist an API request record for audit trail.
|
|
176
|
+
# Silently no-ops when DB is not connected.
|
|
177
|
+
def audit_api_request(http_method:, url:, request_payload: nil, response_status: nil, response_payload: nil, duration_ms: nil,
|
|
178
|
+
error_message: nil, resource_type: nil, resource_id: nil)
|
|
179
|
+
return unless Database.connected?
|
|
180
|
+
|
|
181
|
+
attrs = {
|
|
182
|
+
http_method: http_method,
|
|
183
|
+
url: url,
|
|
184
|
+
request_payload: request_payload || {},
|
|
185
|
+
response_payload: response_payload || {},
|
|
186
|
+
response_status: response_status,
|
|
187
|
+
duration_ms: duration_ms,
|
|
188
|
+
error_message: error_message,
|
|
189
|
+
resource_type: resource_type,
|
|
190
|
+
resource_id: resource_id,
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
# Tag with the current location when the column exists
|
|
194
|
+
if config&.location_id.present? && Models::ApiRequest.column_names.include?("square_location_id")
|
|
195
|
+
attrs[:square_location_id] = config.location_id
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
Models::ApiRequest.create!(attrs)
|
|
199
|
+
rescue StandardError => e
|
|
200
|
+
logger.debug "Audit logging failed: #{e.message}"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# ============================================
|
|
204
|
+
# STANDARDIZED ERROR HANDLING HELPERS
|
|
205
|
+
# ============================================
|
|
206
|
+
|
|
207
|
+
# Execute a block with API error fallback
|
|
208
|
+
# @param fallback [Object] Value to return on error
|
|
209
|
+
# @param log_level [Symbol] Log level for error (:debug, :warn, :error)
|
|
210
|
+
# @param reraise_on [Array<Integer>] HTTP codes to reraise instead of fallback
|
|
211
|
+
# @yield Block to execute
|
|
212
|
+
# @return [Object] Block result or fallback
|
|
213
|
+
def with_api_fallback(fallback: nil, log_level: :debug, reraise_on: [])
|
|
214
|
+
yield
|
|
215
|
+
rescue ApiError => e
|
|
216
|
+
# Reraise if it's a critical error code
|
|
217
|
+
raise if reraise_on.any? { |code| e.message.include?("HTTP #{code}") }
|
|
218
|
+
|
|
219
|
+
logger.send(log_level, "API error (using fallback): #{e.message}")
|
|
220
|
+
fallback
|
|
221
|
+
rescue StandardError => e
|
|
222
|
+
logger.send(log_level, "Error (using fallback): #{e.message}")
|
|
223
|
+
fallback
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Execute a block, handling sandbox limitations (405 errors)
|
|
227
|
+
# @param simulated_response [Object] Response to return if sandbox doesn't support the operation
|
|
228
|
+
# @yield Block to execute
|
|
229
|
+
# @return [Object] Block result or simulated response
|
|
230
|
+
def with_sandbox_fallback(simulated_response: nil)
|
|
231
|
+
yield
|
|
232
|
+
rescue ApiError => e
|
|
233
|
+
raise unless e.message.include?("405")
|
|
234
|
+
|
|
235
|
+
logger.warn "Operation not supported in sandbox environment"
|
|
236
|
+
simulated_response
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Safe getter for nested hash values with logging
|
|
240
|
+
# @param hash [Hash] The hash to extract from
|
|
241
|
+
# @param keys [Array] Keys to dig into
|
|
242
|
+
# @param default [Object] Default value if not found
|
|
243
|
+
# @return [Object] The value or default
|
|
244
|
+
def safe_dig(hash, *keys, default: nil)
|
|
245
|
+
return default if hash.nil?
|
|
246
|
+
|
|
247
|
+
hash.dig(*keys) || default
|
|
248
|
+
rescue StandardError
|
|
249
|
+
default
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SquareSandboxSimulator
|
|
4
|
+
module Services
|
|
5
|
+
module Square
|
|
6
|
+
# Manages Square Catalog: categories, items, discounts, taxes
|
|
7
|
+
class CatalogService < BaseService
|
|
8
|
+
# Upsert a category
|
|
9
|
+
# @param name [String] Category name
|
|
10
|
+
# @return [Hash] Catalog object response
|
|
11
|
+
def upsert_category(name:)
|
|
12
|
+
logger.info "Upserting category: #{name}"
|
|
13
|
+
|
|
14
|
+
payload = {
|
|
15
|
+
"object" => {
|
|
16
|
+
"type" => "CATEGORY",
|
|
17
|
+
"id" => "##{name.downcase.gsub(/\s+/, "_")}",
|
|
18
|
+
"category_data" => {
|
|
19
|
+
"name" => name,
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
request(:post, "catalog/object",
|
|
25
|
+
payload: payload,
|
|
26
|
+
resource_type: "CatalogObject")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Upsert an item with a variation
|
|
30
|
+
# @param name [String] Item name
|
|
31
|
+
# @param price_cents [Integer] Price in cents
|
|
32
|
+
# @param category_id [String, nil] Category ID to associate
|
|
33
|
+
# @return [Hash] Catalog object response
|
|
34
|
+
def upsert_item(name:, price_cents:, category_id: nil)
|
|
35
|
+
logger.info "Upserting item: #{name} ($#{price_cents / 100.0})"
|
|
36
|
+
|
|
37
|
+
item_id = "##{name.downcase.gsub(/\s+/, "_")}"
|
|
38
|
+
variation_id = "#{item_id}_variation"
|
|
39
|
+
|
|
40
|
+
item_data = {
|
|
41
|
+
"name" => name,
|
|
42
|
+
"variations" => [
|
|
43
|
+
{
|
|
44
|
+
"type" => "ITEM_VARIATION",
|
|
45
|
+
"id" => variation_id,
|
|
46
|
+
"item_variation_data" => {
|
|
47
|
+
"name" => "Regular",
|
|
48
|
+
"pricing_type" => "FIXED_PRICING",
|
|
49
|
+
"price_money" => {
|
|
50
|
+
"amount" => price_cents,
|
|
51
|
+
"currency" => "USD",
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
item_data["category_id"] = category_id if category_id
|
|
59
|
+
|
|
60
|
+
payload = {
|
|
61
|
+
"object" => {
|
|
62
|
+
"type" => "ITEM",
|
|
63
|
+
"id" => item_id,
|
|
64
|
+
"item_data" => item_data,
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
request(:post, "catalog/object",
|
|
69
|
+
payload: payload,
|
|
70
|
+
resource_type: "CatalogObject")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Upsert a discount
|
|
74
|
+
# @param name [String] Discount name
|
|
75
|
+
# @param percentage [String, nil] Percentage (e.g. "10.0")
|
|
76
|
+
# @param amount_cents [Integer, nil] Fixed amount in cents
|
|
77
|
+
# @return [Hash] Catalog object response
|
|
78
|
+
def upsert_discount(name:, percentage: nil, amount_cents: nil)
|
|
79
|
+
logger.info "Upserting discount: #{name}"
|
|
80
|
+
|
|
81
|
+
discount_data = { "name" => name }
|
|
82
|
+
|
|
83
|
+
if percentage
|
|
84
|
+
discount_data["discount_type"] = "FIXED_PERCENTAGE"
|
|
85
|
+
discount_data["percentage"] = percentage.to_s
|
|
86
|
+
elsif amount_cents
|
|
87
|
+
discount_data["discount_type"] = "FIXED_AMOUNT"
|
|
88
|
+
discount_data["amount_money"] = {
|
|
89
|
+
"amount" => amount_cents,
|
|
90
|
+
"currency" => "USD",
|
|
91
|
+
}
|
|
92
|
+
else
|
|
93
|
+
raise ArgumentError, "Must provide either percentage or amount_cents"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
payload = {
|
|
97
|
+
"object" => {
|
|
98
|
+
"type" => "DISCOUNT",
|
|
99
|
+
"id" => "#discount_#{name.downcase.gsub(/\s+/, "_")}",
|
|
100
|
+
"discount_data" => discount_data,
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
request(:post, "catalog/object",
|
|
105
|
+
payload: payload,
|
|
106
|
+
resource_type: "CatalogObject")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Upsert a tax
|
|
110
|
+
# @param name [String] Tax name
|
|
111
|
+
# @param percentage [String] Tax percentage (e.g. "8.25")
|
|
112
|
+
# @return [Hash] Catalog object response
|
|
113
|
+
def upsert_tax(name:, percentage:)
|
|
114
|
+
logger.info "Upserting tax: #{name} (#{percentage}%)"
|
|
115
|
+
|
|
116
|
+
payload = {
|
|
117
|
+
"object" => {
|
|
118
|
+
"type" => "TAX",
|
|
119
|
+
"id" => "#tax_#{name.downcase.gsub(/\s+/, "_")}",
|
|
120
|
+
"tax_data" => {
|
|
121
|
+
"name" => name,
|
|
122
|
+
"calculation_phase" => "TAX_SUBTOTAL_PHASE",
|
|
123
|
+
"inclusion_type" => "ADDITIVE",
|
|
124
|
+
"percentage" => percentage.to_s,
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
request(:post, "catalog/object",
|
|
130
|
+
payload: payload,
|
|
131
|
+
resource_type: "CatalogObject")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# List catalog objects
|
|
135
|
+
# @param types [String, nil] Comma-separated types (e.g. "ITEM,CATEGORY")
|
|
136
|
+
# @return [Array<Hash>] Array of catalog objects
|
|
137
|
+
def list_catalog(types: nil)
|
|
138
|
+
logger.info "Listing catalog#{" (types: #{types})" if types}..."
|
|
139
|
+
|
|
140
|
+
params = {}
|
|
141
|
+
params[:types] = types if types
|
|
142
|
+
|
|
143
|
+
response = request(:get, "catalog/list", params: params)
|
|
144
|
+
objects = response&.dig("objects") || []
|
|
145
|
+
logger.info "Found #{objects.size} catalog objects"
|
|
146
|
+
objects
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Get a single catalog object
|
|
150
|
+
# @param object_id [String] Catalog object ID
|
|
151
|
+
# @return [Hash, nil] Catalog object
|
|
152
|
+
def get_object(object_id)
|
|
153
|
+
logger.info "Fetching catalog object: #{object_id}"
|
|
154
|
+
response = request(:get, "catalog/object/#{object_id}")
|
|
155
|
+
response&.dig("object")
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Delete a catalog object
|
|
159
|
+
# @param object_id [String] Catalog object ID
|
|
160
|
+
# @return [Hash, nil] Deleted object IDs
|
|
161
|
+
def delete_object(object_id)
|
|
162
|
+
logger.info "Deleting catalog object: #{object_id}"
|
|
163
|
+
request(:delete, "catalog/object/#{object_id}",
|
|
164
|
+
resource_type: "CatalogObject",
|
|
165
|
+
resource_id: object_id)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Batch upsert catalog objects
|
|
169
|
+
# @param objects [Array<Hash>] Array of catalog object batches
|
|
170
|
+
# @return [Hash] Batch upsert response
|
|
171
|
+
def batch_upsert(objects:)
|
|
172
|
+
logger.info "Batch upserting #{objects.size} objects..."
|
|
173
|
+
|
|
174
|
+
payload = {
|
|
175
|
+
"batches" => [
|
|
176
|
+
{
|
|
177
|
+
"objects" => objects,
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
request(:post, "catalog/batch-upsert",
|
|
183
|
+
payload: payload,
|
|
184
|
+
resource_type: "CatalogObject")
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Delete all catalog objects
|
|
188
|
+
def delete_all
|
|
189
|
+
logger.warn "Deleting all catalog objects..."
|
|
190
|
+
|
|
191
|
+
all_objects = list_catalog
|
|
192
|
+
all_objects.each do |obj|
|
|
193
|
+
with_api_fallback(fallback: nil) do
|
|
194
|
+
delete_object(obj["id"])
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
logger.info "All catalog objects deleted"
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faker"
|
|
4
|
+
|
|
5
|
+
module SquareSandboxSimulator
|
|
6
|
+
module Services
|
|
7
|
+
module Square
|
|
8
|
+
# Manages Square customers
|
|
9
|
+
class CustomerService < BaseService
|
|
10
|
+
# Create a customer
|
|
11
|
+
# @param given_name [String] First name
|
|
12
|
+
# @param family_name [String] Last name
|
|
13
|
+
# @param email [String, nil] Email address
|
|
14
|
+
# @param phone [String, nil] Phone number
|
|
15
|
+
# @return [Hash, nil] Customer response
|
|
16
|
+
def create_customer(given_name:, family_name:, email: nil, phone: nil)
|
|
17
|
+
logger.info "Creating customer: #{given_name} #{family_name}"
|
|
18
|
+
|
|
19
|
+
payload = {
|
|
20
|
+
"given_name" => given_name,
|
|
21
|
+
"family_name" => family_name,
|
|
22
|
+
}
|
|
23
|
+
payload["email_address"] = email if email
|
|
24
|
+
payload["phone_number"] = phone if phone
|
|
25
|
+
|
|
26
|
+
response = request(:post, "customers",
|
|
27
|
+
payload: payload,
|
|
28
|
+
resource_type: "Customer")
|
|
29
|
+
|
|
30
|
+
customer = response&.dig("customer")
|
|
31
|
+
logger.info "Customer created: #{customer["id"]}" if customer
|
|
32
|
+
customer
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# List all customers
|
|
36
|
+
# @return [Array<Hash>] Array of customers
|
|
37
|
+
def list_customers
|
|
38
|
+
logger.info "Listing customers..."
|
|
39
|
+
response = request(:get, "customers")
|
|
40
|
+
customers = response&.dig("customers") || []
|
|
41
|
+
logger.info "Found #{customers.size} customers"
|
|
42
|
+
customers
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Get a single customer
|
|
46
|
+
# @param customer_id [String] The customer ID
|
|
47
|
+
# @return [Hash, nil] Customer response
|
|
48
|
+
def get_customer(customer_id)
|
|
49
|
+
logger.info "Fetching customer: #{customer_id}"
|
|
50
|
+
response = request(:get, "customers/#{customer_id}",
|
|
51
|
+
resource_type: "Customer",
|
|
52
|
+
resource_id: customer_id)
|
|
53
|
+
response&.dig("customer")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Default customer data for deterministic setup
|
|
57
|
+
DEFAULT_CUSTOMERS = [
|
|
58
|
+
{ given: "John", family: "Smith", phone: "+15551000001" },
|
|
59
|
+
{ given: "Jane", family: "Doe", phone: "+15551000002" },
|
|
60
|
+
{ given: "Bob", family: "Johnson", phone: "+15551000003" },
|
|
61
|
+
{ given: "Alice", family: "Williams", phone: "+15551000004" },
|
|
62
|
+
{ given: "Charlie", family: "Brown", phone: "+15551000005" },
|
|
63
|
+
{ given: "Diana", family: "Davis", phone: "+15551000006" },
|
|
64
|
+
{ given: "Eve", family: "Miller", phone: "+15551000007" },
|
|
65
|
+
{ given: "Frank", family: "Wilson", phone: "+15551000008" },
|
|
66
|
+
{ given: "Grace", family: "Moore", phone: "+15551000009" },
|
|
67
|
+
{ given: "Henry", family: "Taylor", phone: "+15551000010" },
|
|
68
|
+
{ given: "Ivy", family: "Anderson", phone: "+15551000011" },
|
|
69
|
+
{ given: "Jack", family: "Thomas", phone: "+15551000012" },
|
|
70
|
+
{ given: "Karen", family: "Jackson", phone: "+15551000013" },
|
|
71
|
+
{ given: "Leo", family: "White", phone: "+15551000014" },
|
|
72
|
+
{ given: "Maria", family: "Harris", phone: "+15551000015" },
|
|
73
|
+
{ given: "Nate", family: "Martin", phone: "+15551000016" },
|
|
74
|
+
{ given: "Olivia", family: "Garcia", phone: "+15551000017" },
|
|
75
|
+
{ given: "Paul", family: "Martinez", phone: "+15551000018" },
|
|
76
|
+
{ given: "Quinn", family: "Robinson", phone: "+15551000019" },
|
|
77
|
+
{ given: "Rachel", family: "Clark", phone: "+15551000020" },
|
|
78
|
+
].freeze
|
|
79
|
+
|
|
80
|
+
# Create sample customers if needed (idempotent)
|
|
81
|
+
# @param count [Integer] Minimum number of customers to ensure exist
|
|
82
|
+
# @return [Array<Hash>] All customers
|
|
83
|
+
def ensure_customers(count: 20)
|
|
84
|
+
existing = list_customers
|
|
85
|
+
return existing if existing.size >= count
|
|
86
|
+
|
|
87
|
+
needed = count - existing.size
|
|
88
|
+
logger.info "Creating #{needed} sample customers..."
|
|
89
|
+
|
|
90
|
+
new_customers = []
|
|
91
|
+
needed.times do |i|
|
|
92
|
+
idx = existing.size + i
|
|
93
|
+
if idx < DEFAULT_CUSTOMERS.size
|
|
94
|
+
cust_data = DEFAULT_CUSTOMERS[idx]
|
|
95
|
+
given = cust_data[:given]
|
|
96
|
+
family = cust_data[:family]
|
|
97
|
+
phone = cust_data[:phone]
|
|
98
|
+
else
|
|
99
|
+
given = Faker::Name.first_name
|
|
100
|
+
family = Faker::Name.last_name
|
|
101
|
+
phone = Faker::PhoneNumber.cell_phone
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
safe_given = given.downcase.gsub(/[^a-z0-9]/, "")
|
|
105
|
+
safe_family = family.downcase.gsub(/[^a-z0-9]/, "")
|
|
106
|
+
|
|
107
|
+
customer = create_customer(
|
|
108
|
+
given_name: given,
|
|
109
|
+
family_name: family,
|
|
110
|
+
email: "#{safe_given}.#{safe_family}@example.com",
|
|
111
|
+
phone: phone,
|
|
112
|
+
)
|
|
113
|
+
new_customers << customer if customer
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
existing + new_customers
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Delete a customer
|
|
120
|
+
# @param customer_id [String] The customer ID
|
|
121
|
+
def delete_customer(customer_id)
|
|
122
|
+
logger.info "Deleting customer: #{customer_id}"
|
|
123
|
+
request(:delete, "customers/#{customer_id}",
|
|
124
|
+
resource_type: "Customer",
|
|
125
|
+
resource_id: customer_id)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|