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,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