skytab_sandbox_simulator 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +10 -0
  3. data/bin/simulate +433 -0
  4. data/lib/skytab_sandbox_simulator/configuration.rb +205 -0
  5. data/lib/skytab_sandbox_simulator/data/bar_nightclub/categories.json +9 -0
  6. data/lib/skytab_sandbox_simulator/data/bar_nightclub/items.json +28 -0
  7. data/lib/skytab_sandbox_simulator/data/bar_nightclub/tenders.json +19 -0
  8. data/lib/skytab_sandbox_simulator/data/cafe_bakery/categories.json +9 -0
  9. data/lib/skytab_sandbox_simulator/data/cafe_bakery/items.json +30 -0
  10. data/lib/skytab_sandbox_simulator/data/cafe_bakery/tenders.json +17 -0
  11. data/lib/skytab_sandbox_simulator/data/fine_dining/categories.json +9 -0
  12. data/lib/skytab_sandbox_simulator/data/fine_dining/items.json +30 -0
  13. data/lib/skytab_sandbox_simulator/data/fine_dining/tenders.json +18 -0
  14. data/lib/skytab_sandbox_simulator/data/pizzeria/categories.json +9 -0
  15. data/lib/skytab_sandbox_simulator/data/pizzeria/items.json +28 -0
  16. data/lib/skytab_sandbox_simulator/data/pizzeria/tenders.json +18 -0
  17. data/lib/skytab_sandbox_simulator/data/restaurant/categories.json +44 -0
  18. data/lib/skytab_sandbox_simulator/data/restaurant/items.json +59 -0
  19. data/lib/skytab_sandbox_simulator/data/restaurant/tenders.json +22 -0
  20. data/lib/skytab_sandbox_simulator/database.rb +192 -0
  21. data/lib/skytab_sandbox_simulator/db/factories/business_types.rb +102 -0
  22. data/lib/skytab_sandbox_simulator/db/factories/categories.rb +243 -0
  23. data/lib/skytab_sandbox_simulator/db/factories/items.rb +976 -0
  24. data/lib/skytab_sandbox_simulator/db/factories/simulated_orders.rb +120 -0
  25. data/lib/skytab_sandbox_simulator/db/factories/simulated_payments.rb +75 -0
  26. data/lib/skytab_sandbox_simulator/db/migrate/20260316000000_enable_pgcrypto.rb +7 -0
  27. data/lib/skytab_sandbox_simulator/db/migrate/20260316000001_create_business_types.rb +18 -0
  28. data/lib/skytab_sandbox_simulator/db/migrate/20260316000002_create_categories.rb +18 -0
  29. data/lib/skytab_sandbox_simulator/db/migrate/20260316000003_create_items.rb +23 -0
  30. data/lib/skytab_sandbox_simulator/db/migrate/20260316000004_create_simulated_orders.rb +35 -0
  31. data/lib/skytab_sandbox_simulator/db/migrate/20260316000005_create_simulated_payments.rb +26 -0
  32. data/lib/skytab_sandbox_simulator/db/migrate/20260316000006_create_api_requests.rb +27 -0
  33. data/lib/skytab_sandbox_simulator/db/migrate/20260316000007_create_daily_summaries.rb +24 -0
  34. data/lib/skytab_sandbox_simulator/generators/data_loader.rb +125 -0
  35. data/lib/skytab_sandbox_simulator/generators/entity_generator.rb +107 -0
  36. data/lib/skytab_sandbox_simulator/generators/order_generator.rb +390 -0
  37. data/lib/skytab_sandbox_simulator/models/api_request.rb +43 -0
  38. data/lib/skytab_sandbox_simulator/models/business_type.rb +25 -0
  39. data/lib/skytab_sandbox_simulator/models/category.rb +17 -0
  40. data/lib/skytab_sandbox_simulator/models/daily_summary.rb +67 -0
  41. data/lib/skytab_sandbox_simulator/models/item.rb +32 -0
  42. data/lib/skytab_sandbox_simulator/models/record.rb +14 -0
  43. data/lib/skytab_sandbox_simulator/models/simulated_order.rb +40 -0
  44. data/lib/skytab_sandbox_simulator/models/simulated_payment.rb +28 -0
  45. data/lib/skytab_sandbox_simulator/seeder.rb +167 -0
  46. data/lib/skytab_sandbox_simulator/services/base_service.rb +227 -0
  47. data/lib/skytab_sandbox_simulator/services/skytab/catalog_service.rb +130 -0
  48. data/lib/skytab_sandbox_simulator/services/skytab/location_service.rb +54 -0
  49. data/lib/skytab_sandbox_simulator/services/skytab/order_service.rb +139 -0
  50. data/lib/skytab_sandbox_simulator/services/skytab/payment_service.rb +94 -0
  51. data/lib/skytab_sandbox_simulator/services/skytab/service_manager.rb +62 -0
  52. data/lib/skytab_sandbox_simulator/version.rb +5 -0
  53. data/lib/skytab_sandbox_simulator.rb +45 -0
  54. metadata +305 -0
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "factory_bot"
4
+
5
+ module SkytabSandboxSimulator
6
+ # Seeds the database with realistic SkyTab sandbox data using FactoryBot factories.
7
+ #
8
+ # Idempotent — safe to run multiple times without creating duplicates.
9
+ # Uses `find_or_create_by!` on unique keys (BusinessType.key,
10
+ # Category.name+business_type, Item.name+category).
11
+ #
12
+ # @example Seed all business types
13
+ # SkytabSandboxSimulator::Seeder.seed!
14
+ #
15
+ # @example Seed a single business type
16
+ # SkytabSandboxSimulator::Seeder.seed!(business_type: :restaurant)
17
+ #
18
+ class Seeder
19
+ # Maps each business type trait to its category traits,
20
+ # and each category trait to its item traits.
21
+ SEED_MAP = {
22
+ restaurant: {
23
+ appetizers: %i[buffalo_wings mozzarella_sticks loaded_nachos spinach_artichoke_dip calamari
24
+ bruschetta shrimp_cocktail],
25
+ entrees: %i[classic_burger grilled_salmon ny_strip_steak chicken_parmesan fettuccine_alfredo
26
+ fish_and_chips bbq_ribs mushroom_risotto shrimp_scampi],
27
+ sides: %i[french_fries sweet_potato_fries onion_rings coleslaw mashed_potatoes
28
+ steamed_vegetables garlic_bread],
29
+ desserts: %i[chocolate_brownie ny_cheesecake apple_pie tiramisu creme_brulee],
30
+ beverages: %i[soft_drink iced_tea lemonade coffee hot_tea sparkling_water fresh_juice],
31
+ alcohol: %i[draft_beer domestic_beer import_beer house_wine margarita old_fashioned],
32
+ kids_menu: %i[chicken_tenders kids_mac_and_cheese mini_burger grilled_cheese kids_quesadilla],
33
+ specials: %i[chefs_special soup_of_the_day]
34
+ },
35
+ cafe_bakery: {
36
+ coffee_espresso: %i[house_drip_coffee espresso cappuccino latte cold_brew],
37
+ pastries: %i[croissant blueberry_muffin cinnamon_roll chocolate_chip_cookie almond_croissant],
38
+ breakfast_items: %i[avocado_toast breakfast_burrito acai_bowl yogurt_parfait],
39
+ sandwiches_wraps: %i[turkey_club caprese_panini chicken_caesar_wrap blt],
40
+ smoothies_juice: %i[berry_blast_smoothie green_detox_juice mango_tango_smoothie fresh_oj]
41
+ },
42
+ bar_nightclub: {
43
+ draft_beer: %i[bar_house_lager bar_ipa bar_stout bar_wheat_beer],
44
+ cocktails: %i[bar_margarita bar_old_fashioned bar_mojito bar_espresso_martini],
45
+ spirits: %i[whiskey_neat vodka_soda tequila_shot rum_and_coke],
46
+ wine_list: %i[bar_house_red bar_house_white bar_prosecco bar_rose],
47
+ bar_snacks: %i[bar_loaded_fries bar_sliders bar_wings bar_pretzel_bites]
48
+ },
49
+ pizzeria: {
50
+ pizzas: %i[margherita pepperoni supreme hawaiian bbq_chicken_pizza meat_lovers],
51
+ calzones: %i[classic_calzone meat_calzone stromboli spinach_calzone],
52
+ pizza_sides: %i[pizza_garlic_bread pizza_garden_salad pizza_caesar_salad garlic_knots],
53
+ pizza_drinks: %i[pizza_fountain_drink pizza_iced_tea pizza_lemonade],
54
+ pizza_desserts: %i[cannoli pizza_brownie pizza_cheesecake]
55
+ },
56
+ fine_dining: {
57
+ first_course: %i[seared_foie_gras lobster_bisque tuna_tartare burrata_salad
58
+ oysters_half_dozen],
59
+ main_course: %i[wagyu_ribeye chilean_sea_bass rack_of_lamb duck_breast truffle_risotto],
60
+ fine_desserts: %i[fine_creme_brulee chocolate_souffle tasting_plate cheese_board
61
+ fine_tiramisu],
62
+ fine_wines: %i[fine_champagne fine_cabernet fine_pinot_noir fine_chardonnay],
63
+ fine_cocktails: %i[fine_negroni fine_manhattan fine_french_75]
64
+ }
65
+ }.freeze
66
+
67
+ # Expected category counts per business type (for spec validation).
68
+ CATEGORY_COUNTS = SEED_MAP.transform_values { |cats| cats.size }.freeze
69
+
70
+ # Expected item counts per category (for spec validation).
71
+ ITEM_COUNTS = SEED_MAP.each_with_object({}) do |(_, cats), hash|
72
+ cats.each { |cat_trait, items| hash[cat_trait] = items.size }
73
+ end.freeze
74
+
75
+ # Total counts across all business types.
76
+ TOTAL_BUSINESS_TYPES = SEED_MAP.size
77
+ TOTAL_CATEGORIES = SEED_MAP.values.sum(&:size)
78
+ TOTAL_ITEMS = SEED_MAP.values.flat_map(&:values).flatten.size
79
+
80
+ # Seed all (or one) business types with categories and items.
81
+ #
82
+ # @param business_type [Symbol, String, nil] Seed only this type, or all if nil.
83
+ # @return [Hash] Summary of created/found counts.
84
+ def self.seed!(business_type: nil)
85
+ new.seed!(business_type: business_type)
86
+ end
87
+
88
+ # @param business_type [Symbol, String, nil]
89
+ # @return [Hash] Summary with :business_types, :categories, :items,
90
+ # :created, :found counts.
91
+ def seed!(business_type: nil)
92
+ types_to_seed = resolve_types(business_type)
93
+
94
+ counts = { business_types: 0, categories: 0, items: 0, created: 0, found: 0 }
95
+
96
+ ActiveRecord::Base.transaction do
97
+ types_to_seed.each do |bt_trait, categories_map|
98
+ bt, was_new = seed_business_type(bt_trait)
99
+ counts[:business_types] += 1
100
+ was_new ? counts[:created] += 1 : counts[:found] += 1
101
+
102
+ categories_map.each do |cat_trait, item_traits|
103
+ cat, was_new = seed_category(cat_trait, bt)
104
+ counts[:categories] += 1
105
+ was_new ? counts[:created] += 1 : counts[:found] += 1
106
+
107
+ item_traits.each do |item_trait|
108
+ _, was_new = seed_item(item_trait, cat)
109
+ counts[:items] += 1
110
+ was_new ? counts[:created] += 1 : counts[:found] += 1
111
+ end
112
+ end
113
+ end
114
+ end
115
+
116
+ SkytabSandboxSimulator.logger.info(
117
+ "Seeding complete: #{counts[:business_types]} business types, " \
118
+ "#{counts[:categories]} categories, #{counts[:items]} items " \
119
+ "(#{counts[:created]} created, #{counts[:found]} found)"
120
+ )
121
+
122
+ counts
123
+ end
124
+
125
+ private
126
+
127
+ # Resolve which business types to seed.
128
+ def resolve_types(business_type)
129
+ return SEED_MAP if business_type.nil?
130
+
131
+ key = business_type.to_sym
132
+ unless SEED_MAP.key?(key)
133
+ raise ArgumentError,
134
+ "Unknown business type: #{key}. Valid types: #{SEED_MAP.keys.join(', ')}"
135
+ end
136
+
137
+ { key => SEED_MAP[key] }
138
+ end
139
+
140
+ # Find or create a business type using factory attributes.
141
+ def seed_business_type(trait)
142
+ attrs = FactoryBot.attributes_for(:business_type, trait)
143
+ record = Models::BusinessType.find_or_create_by!(key: attrs[:key]) do |bt|
144
+ bt.assign_attributes(attrs)
145
+ end
146
+ [record, record.previously_new_record?]
147
+ end
148
+
149
+ # Find or create a category using factory attributes.
150
+ def seed_category(trait, business_type)
151
+ attrs = FactoryBot.attributes_for(:category, trait)
152
+ record = Models::Category.find_or_create_by!(name: attrs[:name], business_type: business_type) do |cat|
153
+ cat.assign_attributes(attrs.except(:business_type_id))
154
+ end
155
+ [record, record.previously_new_record?]
156
+ end
157
+
158
+ # Find or create an item using factory attributes.
159
+ def seed_item(trait, category)
160
+ attrs = FactoryBot.attributes_for(:item, trait)
161
+ record = Models::Item.find_or_create_by!(name: attrs[:name], category: category) do |item|
162
+ item.assign_attributes(attrs.except(:category_id))
163
+ end
164
+ [record, record.previously_new_record?]
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkytabSandboxSimulator
4
+ module Services
5
+ # Base service for all SkyTab API interactions.
6
+ # Provides HTTP client, logging, and error handling.
7
+ class BaseService
8
+ attr_reader :config, :logger
9
+
10
+ def initialize(config: nil)
11
+ @config = config || SkytabSandboxSimulator.configuration
12
+ @config.validate!
13
+ @logger = @config.logger
14
+ end
15
+
16
+ protected
17
+
18
+ # Make HTTP request to SkyTab (Shift4) 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.
23
+ #
24
+ # @param method [Symbol] HTTP method (:get, :post, :put, :delete)
25
+ # @param path [String] API endpoint path
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. "Ticket")
29
+ # @param resource_id [String, nil] 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
+ log_request(method, url, payload)
35
+ start_time = Time.now
36
+
37
+ response = execute_request(method, url, payload)
38
+
39
+ duration_ms = ((Time.now - start_time) * 1000).round
40
+ log_response(response, duration_ms)
41
+
42
+ parsed = parse_response(response)
43
+
44
+ audit_api_request(
45
+ http_method: method.to_s.upcase,
46
+ url: url,
47
+ request_payload: payload,
48
+ response_status: response.code,
49
+ response_payload: parsed,
50
+ duration_ms: duration_ms,
51
+ resource_type: resource_type,
52
+ resource_id: resource_id
53
+ )
54
+
55
+ parsed
56
+ rescue RestClient::ExceptionWithResponse => e
57
+ duration_ms = ((Time.now - start_time) * 1000).round if start_time
58
+
59
+ audit_api_request(
60
+ http_method: method.to_s.upcase,
61
+ url: url,
62
+ request_payload: payload,
63
+ response_status: e.http_code,
64
+ response_payload: (JSON.parse(e.response.body) rescue nil),
65
+ duration_ms: duration_ms,
66
+ error_message: "HTTP #{e.http_code}: #{e.message}",
67
+ resource_type: resource_type,
68
+ resource_id: resource_id
69
+ )
70
+
71
+ handle_api_error(e)
72
+ rescue StandardError => e
73
+ duration_ms = ((Time.now - start_time) * 1000).round if start_time
74
+
75
+ audit_api_request(
76
+ http_method: method.to_s.upcase,
77
+ url: url,
78
+ request_payload: payload,
79
+ duration_ms: duration_ms,
80
+ error_message: e.message,
81
+ resource_type: resource_type,
82
+ resource_id: resource_id
83
+ )
84
+
85
+ logger.error "Request failed: #{e.message}"
86
+ raise ApiError, e.message
87
+ end
88
+
89
+ # Build Marketplace API endpoint path
90
+ #
91
+ # @param path [String] Relative path
92
+ # @return [String] Full endpoint path
93
+ def marketplace_endpoint(path)
94
+ "marketplace/v2/#{path}"
95
+ end
96
+
97
+ # Build POS API endpoint path for a location
98
+ #
99
+ # @param path [String] Relative path after location ID
100
+ # @return [String] Full endpoint path
101
+ def pos_endpoint(path)
102
+ "pos/v2/#{config.location_id}/#{path}"
103
+ end
104
+
105
+ private
106
+
107
+ def headers
108
+ {
109
+ "Authorization" => "Bearer #{config.access_token}",
110
+ "Content-Type" => "application/json",
111
+ "Accept" => "application/json"
112
+ }
113
+ end
114
+
115
+ def build_url(path, params = nil)
116
+ base = path.start_with?("http") ? path : "#{config.environment}#{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
+ def handle_api_error(error)
144
+ body = begin
145
+ JSON.parse(error.response.body)
146
+ rescue StandardError
147
+ { "message" => error.response.body }
148
+ end
149
+
150
+ logger.error "API Error (#{error.http_code}): #{body}"
151
+ raise ApiError, "HTTP #{error.http_code}: #{body["message"] || body}"
152
+ end
153
+
154
+ def log_request(method, url, payload)
155
+ logger.debug "-> #{method.to_s.upcase} #{url}"
156
+ logger.debug " Payload: #{payload.inspect}" if payload
157
+ end
158
+
159
+ def log_response(response, duration_ms)
160
+ logger.debug "<- #{response.code} (#{duration_ms}ms)"
161
+ end
162
+
163
+ # Persist an API request record for audit trail.
164
+ # Silently no-ops when DB is not connected.
165
+ def audit_api_request(http_method:, url:, request_payload: nil, response_status: nil,
166
+ response_payload: nil, duration_ms: nil, error_message: nil,
167
+ resource_type: nil, resource_id: nil)
168
+ return unless Database.connected?
169
+
170
+ attrs = {
171
+ http_method: http_method,
172
+ url: url,
173
+ request_payload: request_payload || {},
174
+ response_payload: response_payload || {},
175
+ response_status: response_status,
176
+ duration_ms: duration_ms,
177
+ error_message: error_message,
178
+ resource_type: resource_type,
179
+ resource_id: resource_id
180
+ }
181
+
182
+ # Tag with the current location when the column exists
183
+ if config&.location_id.present? && Models::ApiRequest.column_names.include?("skytab_location_id")
184
+ attrs[:skytab_location_id] = config.location_id
185
+ end
186
+
187
+ Models::ApiRequest.create!(attrs)
188
+ rescue StandardError => e
189
+ logger.debug "Audit logging failed: #{e.message}"
190
+ end
191
+
192
+ # Execute a block with API error fallback
193
+ def with_api_fallback(fallback: nil, log_level: :debug, reraise_on: [])
194
+ yield
195
+ rescue ApiError => e
196
+ raise if reraise_on.any? { |code| e.message.include?("HTTP #{code}") }
197
+
198
+ logger.send(log_level, "API error (using fallback): #{e.message}")
199
+ fallback
200
+ rescue StandardError => e
201
+ logger.send(log_level, "Error (using fallback): #{e.message}")
202
+ fallback
203
+ end
204
+
205
+ # Execute a block, handling sandbox limitations (405 errors)
206
+ def with_sandbox_fallback(simulated_response: nil)
207
+ yield
208
+ rescue ApiError => e
209
+ if e.message.include?("405")
210
+ logger.warn "Operation not supported in sandbox environment"
211
+ simulated_response
212
+ else
213
+ raise
214
+ end
215
+ end
216
+
217
+ # Safe getter for nested hash values with logging
218
+ def safe_dig(hash, *keys, default: nil)
219
+ return default if hash.nil?
220
+
221
+ hash.dig(*keys) || default
222
+ rescue StandardError
223
+ default
224
+ end
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkytabSandboxSimulator
4
+ module Services
5
+ module Skytab
6
+ # Manages SkyTab catalog: categories, items, taxes, modifier sets.
7
+ # GET /pos/v2/{locationId}/menu
8
+ class CatalogService < BaseService
9
+ # Fetch the full menu for the current location
10
+ #
11
+ # @return [Hash] Menu data with categories, items, taxes, modifierSets
12
+ def get_menu
13
+ logger.info "Fetching menu..."
14
+ response = request(:get, pos_endpoint("menu"),
15
+ resource_type: "Menu")
16
+ logger.info "Menu fetched successfully"
17
+ response || {}
18
+ end
19
+
20
+ # Fetch all categories from the menu
21
+ #
22
+ # @return [Array<Hash>] Array of category objects
23
+ def get_categories
24
+ logger.info "Fetching categories..."
25
+ menu = get_menu
26
+ categories = menu["categories"] || []
27
+ logger.info "Found #{categories.size} categories"
28
+ categories
29
+ end
30
+
31
+ # Fetch all items from the menu
32
+ #
33
+ # @return [Array<Hash>] Array of item objects
34
+ def get_items
35
+ logger.info "Fetching items..."
36
+ menu = get_menu
37
+ items = menu["items"] || []
38
+ logger.info "Found #{items.size} items"
39
+ items
40
+ end
41
+
42
+ # Fetch all tax rates from the menu
43
+ #
44
+ # @return [Array<Hash>] Array of tax rate objects
45
+ def get_taxes
46
+ logger.info "Fetching taxes..."
47
+ menu = get_menu
48
+ taxes = menu["taxes"] || []
49
+ logger.info "Found #{taxes.size} tax rates"
50
+ taxes
51
+ end
52
+
53
+ # Fetch all modifier sets from the menu
54
+ #
55
+ # @return [Array<Hash>] Array of modifier set objects
56
+ def get_modifier_sets
57
+ logger.info "Fetching modifier sets..."
58
+ menu = get_menu
59
+ modifier_sets = menu["modifierSets"] || []
60
+ logger.info "Found #{modifier_sets.size} modifier sets"
61
+ modifier_sets
62
+ end
63
+
64
+ # Create a category via POS API
65
+ #
66
+ # @param name [String] Category name
67
+ # @param sort_order [Integer, nil] Display sort order
68
+ # @return [Hash, nil] Created category
69
+ def create_category(name:, sort_order: nil)
70
+ logger.info "Creating category: #{name}"
71
+ payload = { "name" => name }
72
+ payload["sortOrder"] = sort_order if sort_order
73
+ request(:post, pos_endpoint("categories"), payload: payload,
74
+ resource_type: "Category")
75
+ end
76
+
77
+ # Create an item via POS API
78
+ #
79
+ # @param name [String] Item name
80
+ # @param price [Integer] Price in cents
81
+ # @param category_id [String, nil] Category to associate with
82
+ # @param sku [String, nil] SKU identifier
83
+ # @return [Hash, nil] Created item
84
+ def create_item(name:, price:, category_id: nil, sku: nil)
85
+ logger.info "Creating item: #{name} ($#{price / 100.0})"
86
+
87
+ payload = {
88
+ "name" => name,
89
+ "price" => price
90
+ }
91
+ payload["categoryId"] = category_id if category_id
92
+ payload["sku"] = sku if sku
93
+
94
+ request(:post, pos_endpoint("items"), payload: payload,
95
+ resource_type: "Item")
96
+ end
97
+
98
+ # Delete a category
99
+ #
100
+ # @param category_id [String] Category ID to delete
101
+ # @return [Hash, nil] Response
102
+ def delete_category(category_id)
103
+ logger.info "Deleting category: #{category_id}"
104
+ request(:delete, pos_endpoint("categories/#{category_id}"),
105
+ resource_type: "Category", resource_id: category_id)
106
+ end
107
+
108
+ # Delete an item
109
+ #
110
+ # @param item_id [String] Item ID to delete
111
+ # @return [Hash, nil] Response
112
+ def delete_item(item_id)
113
+ logger.info "Deleting item: #{item_id}"
114
+ request(:delete, pos_endpoint("items/#{item_id}"),
115
+ resource_type: "Item", resource_id: item_id)
116
+ end
117
+
118
+ # Delete all categories and items
119
+ def delete_all
120
+ logger.warn "Deleting all catalog data..."
121
+
122
+ get_items.each { |item| delete_item(item["id"]) }
123
+ get_categories.each { |cat| delete_category(cat["id"]) }
124
+
125
+ logger.info "All catalog data deleted"
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkytabSandboxSimulator
4
+ module Services
5
+ module Skytab
6
+ # Manages SkyTab location information via Shift4 Marketplace API.
7
+ # GET /marketplace/v2/locations
8
+ class LocationService < BaseService
9
+ # Fetch all locations for the authenticated merchant
10
+ #
11
+ # @return [Array<Hash>] Array of location objects
12
+ def get_locations
13
+ logger.info "Fetching locations..."
14
+ response = request(:get, marketplace_endpoint("locations"),
15
+ resource_type: "Location")
16
+ locations = extract_locations(response)
17
+ logger.info "Found #{locations.size} locations"
18
+ locations
19
+ end
20
+
21
+ # Fetch a single location by ID
22
+ #
23
+ # @param location_id [String] Location ID
24
+ # @return [Hash, nil] Location data
25
+ def get_location(location_id)
26
+ logger.info "Fetching location: #{location_id}"
27
+ locations = get_locations
28
+ locations.find { |loc| loc["id"].to_s == location_id.to_s }
29
+ end
30
+
31
+ # Fetch the current configured location
32
+ #
33
+ # @return [Hash, nil] Location data
34
+ def get_current_location
35
+ get_location(config.location_id)
36
+ end
37
+
38
+ private
39
+
40
+ def extract_locations(response)
41
+ return [] if response.nil?
42
+
43
+ if response.is_a?(Array)
44
+ response
45
+ elsif response.is_a?(Hash)
46
+ response["locations"] || response["elements"] || [response]
47
+ else
48
+ []
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end