epos_now_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/Gemfile +10 -0
- data/LICENSE +21 -0
- data/README.md +380 -0
- data/bin/simulate +309 -0
- data/lib/epos_now_sandbox_simulator/configuration.rb +173 -0
- data/lib/epos_now_sandbox_simulator/data/bar_nightclub/categories.json +9 -0
- data/lib/epos_now_sandbox_simulator/data/bar_nightclub/items.json +26 -0
- data/lib/epos_now_sandbox_simulator/data/bar_nightclub/tenders.json +8 -0
- data/lib/epos_now_sandbox_simulator/data/cafe_bakery/categories.json +9 -0
- data/lib/epos_now_sandbox_simulator/data/cafe_bakery/items.json +28 -0
- data/lib/epos_now_sandbox_simulator/data/cafe_bakery/tenders.json +8 -0
- data/lib/epos_now_sandbox_simulator/data/restaurant/categories.json +9 -0
- data/lib/epos_now_sandbox_simulator/data/restaurant/items.json +29 -0
- data/lib/epos_now_sandbox_simulator/data/restaurant/tenders.json +9 -0
- data/lib/epos_now_sandbox_simulator/data/retail_general/categories.json +9 -0
- data/lib/epos_now_sandbox_simulator/data/retail_general/items.json +17 -0
- data/lib/epos_now_sandbox_simulator/data/retail_general/tenders.json +8 -0
- data/lib/epos_now_sandbox_simulator/database.rb +136 -0
- data/lib/epos_now_sandbox_simulator/db/factories/api_requests.rb +13 -0
- data/lib/epos_now_sandbox_simulator/db/factories/business_types.rb +34 -0
- data/lib/epos_now_sandbox_simulator/db/factories/categories.rb +10 -0
- data/lib/epos_now_sandbox_simulator/db/factories/items.rb +12 -0
- data/lib/epos_now_sandbox_simulator/db/factories/simulated_orders.rb +25 -0
- data/lib/epos_now_sandbox_simulator/db/factories/simulated_payments.rb +14 -0
- data/lib/epos_now_sandbox_simulator/db/migrate/20260312000001_enable_pgcrypto.rb +7 -0
- data/lib/epos_now_sandbox_simulator/db/migrate/20260312000002_create_business_types.rb +16 -0
- data/lib/epos_now_sandbox_simulator/db/migrate/20260312000003_create_categories.rb +16 -0
- data/lib/epos_now_sandbox_simulator/db/migrate/20260312000004_create_items.rb +19 -0
- data/lib/epos_now_sandbox_simulator/db/migrate/20260312000005_create_simulated_orders.rb +27 -0
- data/lib/epos_now_sandbox_simulator/db/migrate/20260312000006_create_simulated_payments.rb +22 -0
- data/lib/epos_now_sandbox_simulator/db/migrate/20260312000007_create_api_requests.rb +22 -0
- data/lib/epos_now_sandbox_simulator/db/migrate/20260312000008_create_daily_summaries.rb +21 -0
- data/lib/epos_now_sandbox_simulator/generators/data_loader.rb +100 -0
- data/lib/epos_now_sandbox_simulator/generators/entity_generator.rb +103 -0
- data/lib/epos_now_sandbox_simulator/generators/order_generator.rb +336 -0
- data/lib/epos_now_sandbox_simulator/models/api_request.rb +16 -0
- data/lib/epos_now_sandbox_simulator/models/business_type.rb +16 -0
- data/lib/epos_now_sandbox_simulator/models/category.rb +18 -0
- data/lib/epos_now_sandbox_simulator/models/daily_summary.rb +43 -0
- data/lib/epos_now_sandbox_simulator/models/item.rb +20 -0
- data/lib/epos_now_sandbox_simulator/models/simulated_order.rb +21 -0
- data/lib/epos_now_sandbox_simulator/models/simulated_payment.rb +17 -0
- data/lib/epos_now_sandbox_simulator/seeder.rb +119 -0
- data/lib/epos_now_sandbox_simulator/services/base_service.rb +248 -0
- data/lib/epos_now_sandbox_simulator/services/epos_now/inventory_service.rb +178 -0
- data/lib/epos_now_sandbox_simulator/services/epos_now/services_manager.rb +56 -0
- data/lib/epos_now_sandbox_simulator/services/epos_now/tax_service.rb +45 -0
- data/lib/epos_now_sandbox_simulator/services/epos_now/tender_service.rb +90 -0
- data/lib/epos_now_sandbox_simulator/services/epos_now/transaction_service.rb +171 -0
- data/lib/epos_now_sandbox_simulator.rb +49 -0
- metadata +334 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
|
|
5
|
+
module EposNowSandboxSimulator
|
|
6
|
+
module Services
|
|
7
|
+
# Base service for all Epos Now API interactions.
|
|
8
|
+
#
|
|
9
|
+
# Epos Now uses Basic Authentication: Base64(api_key:api_secret)
|
|
10
|
+
# API Base: https://api.eposnowhq.com/api/v4/
|
|
11
|
+
# Pagination: page-based, 200 items per page
|
|
12
|
+
#
|
|
13
|
+
# V4 differences from V2:
|
|
14
|
+
# - Field names: Id instead of CategoryID/ProductID/etc.
|
|
15
|
+
# - Batch operations: POST/PUT/DELETE accept arrays
|
|
16
|
+
# - DELETE uses request body [{Id: int}]
|
|
17
|
+
# - Transaction: ServiceType instead of EatOut, GetByDate endpoint
|
|
18
|
+
# - TenderType: ClassificationId, IsTipAdjustable fields
|
|
19
|
+
class BaseService
|
|
20
|
+
attr_reader :config, :logger
|
|
21
|
+
|
|
22
|
+
# Epos Now API V4 path prefix
|
|
23
|
+
API_PREFIX = "api/v4"
|
|
24
|
+
|
|
25
|
+
def initialize(config: nil)
|
|
26
|
+
@config = config || EposNowSandboxSimulator.configuration
|
|
27
|
+
@config.validate!
|
|
28
|
+
@logger = @config.logger
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
protected
|
|
32
|
+
|
|
33
|
+
# Make HTTP request to Epos Now API
|
|
34
|
+
#
|
|
35
|
+
# @param method [Symbol] HTTP method (:get, :post, :put, :delete)
|
|
36
|
+
# @param path [String] API endpoint path (e.g. "Category")
|
|
37
|
+
# @param payload [Hash, nil] Request body for POST/PUT
|
|
38
|
+
# @param params [Hash, nil] Query parameters
|
|
39
|
+
# @param resource_type [String, nil] Logical resource (e.g. "Category")
|
|
40
|
+
# @param resource_id [String, nil] Resource ID
|
|
41
|
+
# @return [Hash, Array, nil] Parsed JSON response
|
|
42
|
+
def request(method, path, payload: nil, params: nil, resource_type: nil, resource_id: nil)
|
|
43
|
+
url = build_url(path, params)
|
|
44
|
+
|
|
45
|
+
log_request(method, url, payload)
|
|
46
|
+
start_time = Time.now
|
|
47
|
+
|
|
48
|
+
response = execute_request(method, url, payload)
|
|
49
|
+
|
|
50
|
+
duration_ms = ((Time.now - start_time) * 1000).round
|
|
51
|
+
log_response(response, duration_ms)
|
|
52
|
+
|
|
53
|
+
parsed = parse_response(response)
|
|
54
|
+
|
|
55
|
+
audit_api_request(
|
|
56
|
+
http_method: method.to_s.upcase,
|
|
57
|
+
url: url,
|
|
58
|
+
request_payload: payload,
|
|
59
|
+
response_status: response.code,
|
|
60
|
+
response_payload: parsed,
|
|
61
|
+
duration_ms: duration_ms,
|
|
62
|
+
resource_type: resource_type,
|
|
63
|
+
resource_id: resource_id
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
parsed
|
|
67
|
+
rescue RestClient::ExceptionWithResponse => e
|
|
68
|
+
duration_ms = ((Time.now - start_time) * 1000).round
|
|
69
|
+
|
|
70
|
+
audit_api_request(
|
|
71
|
+
http_method: method.to_s.upcase,
|
|
72
|
+
url: url,
|
|
73
|
+
request_payload: payload,
|
|
74
|
+
response_status: e.http_code,
|
|
75
|
+
response_payload: begin
|
|
76
|
+
JSON.parse(e.response.body)
|
|
77
|
+
rescue StandardError
|
|
78
|
+
nil
|
|
79
|
+
end,
|
|
80
|
+
duration_ms: duration_ms,
|
|
81
|
+
error_message: "HTTP #{e.http_code}: #{e.message}",
|
|
82
|
+
resource_type: resource_type,
|
|
83
|
+
resource_id: resource_id
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
handle_api_error(e)
|
|
87
|
+
rescue StandardError => e
|
|
88
|
+
duration_ms = ((Time.now - start_time) * 1000).round
|
|
89
|
+
|
|
90
|
+
audit_api_request(
|
|
91
|
+
http_method: method.to_s.upcase,
|
|
92
|
+
url: url,
|
|
93
|
+
request_payload: payload,
|
|
94
|
+
duration_ms: duration_ms,
|
|
95
|
+
error_message: e.message,
|
|
96
|
+
resource_type: resource_type,
|
|
97
|
+
resource_id: resource_id
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
logger.error "Request failed: #{e.message}"
|
|
101
|
+
raise ApiError, e.message
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Build endpoint path for Epos Now V4 API
|
|
105
|
+
#
|
|
106
|
+
# @param resource [String] Resource name (e.g. "Category", "Transaction")
|
|
107
|
+
# @return [String] Full endpoint path
|
|
108
|
+
def endpoint(resource)
|
|
109
|
+
"#{API_PREFIX}/#{resource}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Fetch all pages of a paginated resource
|
|
113
|
+
#
|
|
114
|
+
# Epos Now returns up to 200 items per page.
|
|
115
|
+
# Returns empty array when page returns no results.
|
|
116
|
+
#
|
|
117
|
+
# @param resource [String] Resource name
|
|
118
|
+
# @param params [Hash] Additional query parameters
|
|
119
|
+
# @return [Array<Hash>] All records across all pages
|
|
120
|
+
def fetch_all_pages(resource, params: {})
|
|
121
|
+
all_records = []
|
|
122
|
+
page = 1
|
|
123
|
+
|
|
124
|
+
loop do
|
|
125
|
+
page_params = params.merge(page: page)
|
|
126
|
+
results = request(:get, endpoint(resource), params: page_params, resource_type: resource)
|
|
127
|
+
|
|
128
|
+
# Epos Now returns array directly or nil/empty for last page
|
|
129
|
+
records = results.is_a?(Array) ? results : []
|
|
130
|
+
break if records.empty?
|
|
131
|
+
|
|
132
|
+
all_records.concat(records)
|
|
133
|
+
|
|
134
|
+
# If we got fewer than 200, this is the last page
|
|
135
|
+
break if records.size < 200
|
|
136
|
+
|
|
137
|
+
page += 1
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
all_records
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
def headers
|
|
146
|
+
{
|
|
147
|
+
"Authorization" => "Basic #{config.auth_token}",
|
|
148
|
+
"Content-Type" => "application/json",
|
|
149
|
+
"Accept" => "application/json"
|
|
150
|
+
}
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def build_url(path, params = nil)
|
|
154
|
+
base = path.start_with?("http") ? path : "#{config.environment}#{path}"
|
|
155
|
+
return base unless params&.any?
|
|
156
|
+
|
|
157
|
+
uri = URI(base)
|
|
158
|
+
uri.query = URI.encode_www_form(params)
|
|
159
|
+
uri.to_s
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def execute_request(method, url, payload)
|
|
163
|
+
case method
|
|
164
|
+
when :get then RestClient.get(url, headers)
|
|
165
|
+
when :post then RestClient.post(url, payload&.to_json, headers)
|
|
166
|
+
when :put then RestClient.put(url, payload&.to_json, headers)
|
|
167
|
+
when :delete
|
|
168
|
+
# V4 DELETE uses request body [{Id: int}]
|
|
169
|
+
if payload
|
|
170
|
+
RestClient::Request.execute(method: :delete, url: url, payload: payload.to_json, headers: headers)
|
|
171
|
+
else
|
|
172
|
+
RestClient.delete(url, headers)
|
|
173
|
+
end
|
|
174
|
+
else raise ArgumentError, "Unsupported HTTP method: #{method}"
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def parse_response(response)
|
|
179
|
+
return nil if response.body.nil? || response.body.empty?
|
|
180
|
+
|
|
181
|
+
JSON.parse(response.body)
|
|
182
|
+
rescue JSON::ParserError => e
|
|
183
|
+
logger.error "Failed to parse response: #{e.message}"
|
|
184
|
+
raise ApiError, "Invalid JSON response"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def handle_api_error(error)
|
|
188
|
+
body = begin
|
|
189
|
+
JSON.parse(error.response.body)
|
|
190
|
+
rescue StandardError
|
|
191
|
+
{ "message" => error.response.body }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
logger.error "API Error (#{error.http_code}): #{body}"
|
|
195
|
+
raise ApiError, "HTTP #{error.http_code}: #{body["message"] || body}"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def log_request(method, url, payload)
|
|
199
|
+
logger.debug "-> #{method.to_s.upcase} #{url}"
|
|
200
|
+
logger.debug " Payload: #{payload.inspect}" if payload
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def log_response(response, duration_ms)
|
|
204
|
+
logger.debug "<- #{response.code} (#{duration_ms}ms)"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Persist an API request record for audit trail.
|
|
208
|
+
# Silently no-ops when DB is not connected.
|
|
209
|
+
def audit_api_request(http_method:, url:, request_payload: nil, response_status: nil, response_payload: nil, duration_ms: nil,
|
|
210
|
+
error_message: nil, resource_type: nil, resource_id: nil)
|
|
211
|
+
return unless Database.connected?
|
|
212
|
+
|
|
213
|
+
Models::ApiRequest.create!(
|
|
214
|
+
http_method: http_method,
|
|
215
|
+
url: url,
|
|
216
|
+
request_payload: request_payload || {},
|
|
217
|
+
response_payload: response_payload || {},
|
|
218
|
+
response_status: response_status,
|
|
219
|
+
duration_ms: duration_ms,
|
|
220
|
+
error_message: error_message,
|
|
221
|
+
resource_type: resource_type,
|
|
222
|
+
resource_id: resource_id
|
|
223
|
+
)
|
|
224
|
+
rescue StandardError => e
|
|
225
|
+
logger.debug "Audit logging failed: #{e.message}"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Execute a block with API error fallback
|
|
229
|
+
def with_api_fallback(fallback: nil, log_level: :debug)
|
|
230
|
+
yield
|
|
231
|
+
rescue ApiError => e
|
|
232
|
+
logger.send(log_level, "API error (using fallback): #{e.message}")
|
|
233
|
+
fallback
|
|
234
|
+
rescue StandardError => e
|
|
235
|
+
logger.send(log_level, "Error (using fallback): #{e.message}")
|
|
236
|
+
fallback
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def safe_dig(hash, *keys, default: nil)
|
|
240
|
+
return default if hash.nil?
|
|
241
|
+
|
|
242
|
+
hash.dig(*keys) || default
|
|
243
|
+
rescue StandardError
|
|
244
|
+
default
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EposNowSandboxSimulator
|
|
4
|
+
module Services
|
|
5
|
+
module EposNow
|
|
6
|
+
# Manages categories and products in Epos Now via V4 API.
|
|
7
|
+
#
|
|
8
|
+
# V4 endpoints:
|
|
9
|
+
# GET/POST/PUT/DELETE /api/v4/Category
|
|
10
|
+
# GET/POST/PUT/DELETE /api/v4/Product
|
|
11
|
+
#
|
|
12
|
+
# V4 key changes from V2:
|
|
13
|
+
# - Id instead of CategoryID/ProductID
|
|
14
|
+
# - POST/PUT/DELETE accept arrays (batch operations)
|
|
15
|
+
# - Product: IsSalePriceIncTax, IsEatOutPriceIncTax, IsArchived, ColourId
|
|
16
|
+
# - Category: ImageUrl, Children (nested), RootParentId
|
|
17
|
+
class InventoryService < BaseService
|
|
18
|
+
# ==========================================
|
|
19
|
+
# CATEGORIES
|
|
20
|
+
# ==========================================
|
|
21
|
+
|
|
22
|
+
# Fetch all categories (paginated, 200 per page)
|
|
23
|
+
# @return [Array<Hash>] All categories
|
|
24
|
+
def fetch_categories
|
|
25
|
+
logger.info "Fetching all categories..."
|
|
26
|
+
categories = fetch_all_pages("Category")
|
|
27
|
+
logger.info "Fetched #{categories.size} categories"
|
|
28
|
+
categories
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Create a category
|
|
32
|
+
# @param name [String] Category name
|
|
33
|
+
# @param description [String, nil] Description
|
|
34
|
+
# @param show_on_till [Boolean] Show on till (default true)
|
|
35
|
+
# @param sort_position [Integer, nil] Sort order
|
|
36
|
+
# @param parent_id [Integer, nil] Parent category ID
|
|
37
|
+
# @param nominal_code [String, nil] Accounting software identifier
|
|
38
|
+
# @return [Hash] Created category
|
|
39
|
+
def create_category(name:, description: nil, show_on_till: true, sort_position: nil, parent_id: nil, nominal_code: nil)
|
|
40
|
+
payload = {
|
|
41
|
+
"Name" => name,
|
|
42
|
+
"ShowOnTill" => show_on_till,
|
|
43
|
+
"IsWet" => false
|
|
44
|
+
}
|
|
45
|
+
payload["Description"] = description if description
|
|
46
|
+
payload["SortPosition"] = sort_position if sort_position
|
|
47
|
+
payload["ParentId"] = parent_id if parent_id
|
|
48
|
+
payload["NominalCode"] = nominal_code if nominal_code
|
|
49
|
+
|
|
50
|
+
logger.info "Creating category: #{name}"
|
|
51
|
+
result = request(:post, endpoint("Category"), payload: payload, resource_type: "Category")
|
|
52
|
+
logger.info "Created category: #{result["Name"]} (ID: #{result["Id"]})"
|
|
53
|
+
result
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Find category by name
|
|
57
|
+
# @param name [String] Category name to search
|
|
58
|
+
# @return [Hash, nil] Category or nil
|
|
59
|
+
def find_category_by_name(name)
|
|
60
|
+
# V4: use search parameter
|
|
61
|
+
results = request(:get, endpoint("Category"), params: { "Name" => name }, resource_type: "Category")
|
|
62
|
+
return nil unless results.is_a?(Array)
|
|
63
|
+
|
|
64
|
+
results.find { |c| c["Name"]&.downcase == name.downcase }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Create category idempotently (find or create)
|
|
68
|
+
# @return [Hash] Existing or new category
|
|
69
|
+
def find_or_create_category(name:, description: nil, show_on_till: true, sort_position: nil, parent_id: nil, nominal_code: nil)
|
|
70
|
+
existing = find_category_by_name(name)
|
|
71
|
+
return existing if existing
|
|
72
|
+
|
|
73
|
+
create_category(
|
|
74
|
+
name: name,
|
|
75
|
+
description: description,
|
|
76
|
+
show_on_till: show_on_till,
|
|
77
|
+
sort_position: sort_position,
|
|
78
|
+
parent_id: parent_id,
|
|
79
|
+
nominal_code: nominal_code
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Delete a category (V4: uses request body)
|
|
84
|
+
# @param id [Integer] Category ID
|
|
85
|
+
def delete_category(id)
|
|
86
|
+
request(:delete, endpoint("Category"), payload: [{ "Id" => id }], resource_type: "Category", resource_id: id.to_s)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# ==========================================
|
|
90
|
+
# PRODUCTS (Items)
|
|
91
|
+
# ==========================================
|
|
92
|
+
|
|
93
|
+
# Fetch all products (paginated, 200 per page)
|
|
94
|
+
# @return [Array<Hash>] All products
|
|
95
|
+
def fetch_products
|
|
96
|
+
logger.info "Fetching all products..."
|
|
97
|
+
products = fetch_all_pages("Product")
|
|
98
|
+
logger.info "Fetched #{products.size} products"
|
|
99
|
+
products
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Get a single product by ID
|
|
103
|
+
# @param id [Integer] Product ID
|
|
104
|
+
# @return [Hash] Product
|
|
105
|
+
def get_product(id)
|
|
106
|
+
request(:get, endpoint("Product/#{id}"), resource_type: "Product", resource_id: id.to_s)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Create a product
|
|
110
|
+
# @param name [String] Product name
|
|
111
|
+
# @param sale_price [Float] Sale price
|
|
112
|
+
# @param cost_price [Float] Cost price
|
|
113
|
+
# @param eat_out_price [Float, nil] Takeaway price (defaults to sale_price)
|
|
114
|
+
# @param category_id [Integer, nil] Category ID
|
|
115
|
+
# @param barcode [String, nil] Barcode
|
|
116
|
+
# @param sku [String, nil] SKU
|
|
117
|
+
# @param description [String, nil] Description
|
|
118
|
+
# @param sell_on_till [Boolean] Show on till
|
|
119
|
+
# @return [Hash] Created product
|
|
120
|
+
def create_product(name:, sale_price:, cost_price: 0.0, eat_out_price: nil, category_id: nil,
|
|
121
|
+
barcode: nil, sku: nil, description: nil, sell_on_till: true)
|
|
122
|
+
payload = {
|
|
123
|
+
"Name" => name,
|
|
124
|
+
"SalePrice" => sale_price,
|
|
125
|
+
"CostPrice" => cost_price,
|
|
126
|
+
"EatOutPrice" => eat_out_price || sale_price,
|
|
127
|
+
"SellOnTill" => sell_on_till,
|
|
128
|
+
"SellOnWeb" => false,
|
|
129
|
+
"ProductType" => 0 # Standard
|
|
130
|
+
}
|
|
131
|
+
payload["CategoryId"] = category_id if category_id
|
|
132
|
+
payload["Barcode"] = barcode if barcode
|
|
133
|
+
payload["Sku"] = sku if sku
|
|
134
|
+
payload["Description"] = description if description
|
|
135
|
+
|
|
136
|
+
logger.info "Creating product: #{name} ($#{sale_price})"
|
|
137
|
+
result = request(:post, endpoint("Product"), payload: payload, resource_type: "Product")
|
|
138
|
+
logger.info "Created product: #{result["Name"]} (ID: #{result["Id"]})"
|
|
139
|
+
result
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Find product by name
|
|
143
|
+
# @param name [String] Product name
|
|
144
|
+
# @return [Hash, nil] Product or nil
|
|
145
|
+
def find_product_by_name(name)
|
|
146
|
+
results = request(:get, endpoint("Product"), params: { "Name" => name }, resource_type: "Product")
|
|
147
|
+
return nil unless results.is_a?(Array)
|
|
148
|
+
|
|
149
|
+
results.find { |p| p["Name"]&.downcase == name.downcase }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Create product idempotently
|
|
153
|
+
def find_or_create_product(name:, sale_price:, cost_price: 0.0, eat_out_price: nil,
|
|
154
|
+
category_id: nil, barcode: nil, sku: nil, description: nil)
|
|
155
|
+
existing = find_product_by_name(name)
|
|
156
|
+
return existing if existing
|
|
157
|
+
|
|
158
|
+
create_product(
|
|
159
|
+
name: name,
|
|
160
|
+
sale_price: sale_price,
|
|
161
|
+
cost_price: cost_price,
|
|
162
|
+
eat_out_price: eat_out_price,
|
|
163
|
+
category_id: category_id,
|
|
164
|
+
barcode: barcode,
|
|
165
|
+
sku: sku,
|
|
166
|
+
description: description
|
|
167
|
+
)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Delete a product (V4: uses request body)
|
|
171
|
+
# @param id [Integer] Product ID
|
|
172
|
+
def delete_product(id)
|
|
173
|
+
request(:delete, endpoint("Product"), payload: [{ "Id" => id }], resource_type: "Product", resource_id: id.to_s)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent"
|
|
4
|
+
|
|
5
|
+
module EposNowSandboxSimulator
|
|
6
|
+
module Services
|
|
7
|
+
module EposNow
|
|
8
|
+
# Thread-safe, lazy-loaded service manager for all Epos Now services.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# services = ServicesManager.new
|
|
12
|
+
# services.inventory.create_category(name: "Drinks")
|
|
13
|
+
# services.transaction.create_transaction(eat_out: 0)
|
|
14
|
+
class ServicesManager
|
|
15
|
+
def initialize(config: nil)
|
|
16
|
+
@config = config || EposNowSandboxSimulator.configuration
|
|
17
|
+
@mutex = Mutex.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def inventory
|
|
21
|
+
thread_safe_memoize(:@inventory) { InventoryService.new(config: @config) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def tender
|
|
25
|
+
thread_safe_memoize(:@tender) { TenderService.new(config: @config) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def transaction
|
|
29
|
+
thread_safe_memoize(:@transaction) { TransactionService.new(config: @config) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def tax
|
|
33
|
+
thread_safe_memoize(:@tax) { TaxService.new(config: @config) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def thread_safe_memoize(ivar_name)
|
|
39
|
+
value = instance_variable_get(ivar_name)
|
|
40
|
+
return value if value
|
|
41
|
+
|
|
42
|
+
@mutex.synchronize do
|
|
43
|
+
# :nocov:
|
|
44
|
+
value = instance_variable_get(ivar_name)
|
|
45
|
+
return value if value
|
|
46
|
+
# :nocov:
|
|
47
|
+
|
|
48
|
+
value = yield
|
|
49
|
+
instance_variable_set(ivar_name, value)
|
|
50
|
+
value
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EposNowSandboxSimulator
|
|
4
|
+
module Services
|
|
5
|
+
module EposNow
|
|
6
|
+
# Manages tax groups in Epos Now via V4 API.
|
|
7
|
+
#
|
|
8
|
+
# V4 endpoints:
|
|
9
|
+
# GET/POST/PUT/DELETE /api/v4/TaxGroup
|
|
10
|
+
# GET /api/v4/TaxGroup/{id}
|
|
11
|
+
#
|
|
12
|
+
# V4 TaxGroup model:
|
|
13
|
+
# Id, Name, TaxRates[] (array of TaxGroupRate)
|
|
14
|
+
#
|
|
15
|
+
# TaxGroupRate:
|
|
16
|
+
# TaxGroupId, TaxRateId, LocationId, Priority, Percentage, Name, Description, TaxCode
|
|
17
|
+
class TaxService < BaseService
|
|
18
|
+
# Fetch all tax groups
|
|
19
|
+
# @return [Array<Hash>] All tax groups
|
|
20
|
+
def fetch_tax_groups
|
|
21
|
+
logger.info "Fetching tax groups..."
|
|
22
|
+
groups = fetch_all_pages("TaxGroup")
|
|
23
|
+
logger.info "Fetched #{groups.size} tax groups"
|
|
24
|
+
groups
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Get a single tax group by ID
|
|
28
|
+
# @param id [Integer] Tax group ID
|
|
29
|
+
# @return [Hash] Tax group with nested TaxRates
|
|
30
|
+
def get_tax_group(id)
|
|
31
|
+
request(:get, endpoint("TaxGroup/#{id}"), resource_type: "TaxGroup", resource_id: id.to_s)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Calculate tax for a given amount using the configured rate
|
|
35
|
+
# @param amount [Float] Pre-tax amount
|
|
36
|
+
# @param rate [Float, nil] Tax rate percentage (uses config default)
|
|
37
|
+
# @return [Float] Tax amount
|
|
38
|
+
def calculate_tax(amount, rate: nil)
|
|
39
|
+
tax_rate = rate || config.tax_rate
|
|
40
|
+
(amount * tax_rate / 100.0).round(2)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EposNowSandboxSimulator
|
|
4
|
+
module Services
|
|
5
|
+
module EposNow
|
|
6
|
+
# Manages tender types (payment methods) in Epos Now via V4 API.
|
|
7
|
+
#
|
|
8
|
+
# V4 endpoints:
|
|
9
|
+
# GET/POST /api/v4/TenderType
|
|
10
|
+
# GET /api/v4/TenderType/{id}
|
|
11
|
+
#
|
|
12
|
+
# V4 TenderType fields:
|
|
13
|
+
# Id, Name, Description, IsIntegrationTenderType,
|
|
14
|
+
# TillOrder, ClassificationId, IsTipAdjustable, IsWaiterBanked
|
|
15
|
+
#
|
|
16
|
+
# V4 ClassificationId values:
|
|
17
|
+
# Cash, Card, Integrated, Other
|
|
18
|
+
#
|
|
19
|
+
# Note: In V4, individual Tenders (payments) are embedded in
|
|
20
|
+
# Transaction creation — not a separate CRUD endpoint.
|
|
21
|
+
class TenderService < BaseService
|
|
22
|
+
# Default tender types for a typical POS setup
|
|
23
|
+
DEFAULT_TENDER_TYPES = [
|
|
24
|
+
{ name: "Cash", description: "Cash payment" },
|
|
25
|
+
{ name: "Credit Card", description: "Credit card payment" },
|
|
26
|
+
{ name: "Debit Card", description: "Debit card payment" },
|
|
27
|
+
{ name: "Gift Card", description: "Gift card payment" },
|
|
28
|
+
{ name: "Check", description: "Check payment" }
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
# Fetch all tender types
|
|
32
|
+
# @return [Array<Hash>] All tender types
|
|
33
|
+
def fetch_tender_types
|
|
34
|
+
logger.info "Fetching all tender types..."
|
|
35
|
+
types = fetch_all_pages("TenderType")
|
|
36
|
+
logger.info "Fetched #{types.size} tender types"
|
|
37
|
+
types
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Get a single tender type by ID
|
|
41
|
+
# @param id [Integer] Tender type ID
|
|
42
|
+
# @return [Hash] Tender type
|
|
43
|
+
def get_tender_type(id)
|
|
44
|
+
request(:get, endpoint("TenderType/#{id}"), resource_type: "TenderType", resource_id: id.to_s)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Create a tender type
|
|
48
|
+
# @param name [String] Tender type name
|
|
49
|
+
# @param description [String, nil] Description
|
|
50
|
+
# @return [Hash] Created tender type
|
|
51
|
+
def create_tender_type(name:, description: nil)
|
|
52
|
+
payload = {
|
|
53
|
+
"Name" => name
|
|
54
|
+
}
|
|
55
|
+
payload["Description"] = description if description
|
|
56
|
+
|
|
57
|
+
logger.info "Creating tender type: #{name}"
|
|
58
|
+
result = request(:post, endpoint("TenderType"), payload: payload, resource_type: "TenderType")
|
|
59
|
+
logger.info "Created tender type: #{result["Name"]} (ID: #{result["Id"]})"
|
|
60
|
+
result
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Find tender type by name
|
|
64
|
+
# @param name [String] Tender type name
|
|
65
|
+
# @return [Hash, nil] Tender type or nil
|
|
66
|
+
def find_tender_type_by_name(name)
|
|
67
|
+
types = fetch_tender_types
|
|
68
|
+
types.find { |t| t["Name"]&.downcase == name.downcase }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Create tender type idempotently
|
|
72
|
+
def find_or_create_tender_type(name:, description: nil)
|
|
73
|
+
existing = find_tender_type_by_name(name)
|
|
74
|
+
return existing if existing
|
|
75
|
+
|
|
76
|
+
create_tender_type(name: name, description: description)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Ensure default tender types exist
|
|
80
|
+
# @return [Array<Hash>] All tender types (existing + created)
|
|
81
|
+
def setup_default_tender_types
|
|
82
|
+
logger.info "Setting up default tender types..."
|
|
83
|
+
DEFAULT_TENDER_TYPES.map do |tt|
|
|
84
|
+
find_or_create_tender_type(name: tt[:name], description: tt[:description])
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|