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.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +10 -0
  3. data/LICENSE +21 -0
  4. data/README.md +380 -0
  5. data/bin/simulate +309 -0
  6. data/lib/epos_now_sandbox_simulator/configuration.rb +173 -0
  7. data/lib/epos_now_sandbox_simulator/data/bar_nightclub/categories.json +9 -0
  8. data/lib/epos_now_sandbox_simulator/data/bar_nightclub/items.json +26 -0
  9. data/lib/epos_now_sandbox_simulator/data/bar_nightclub/tenders.json +8 -0
  10. data/lib/epos_now_sandbox_simulator/data/cafe_bakery/categories.json +9 -0
  11. data/lib/epos_now_sandbox_simulator/data/cafe_bakery/items.json +28 -0
  12. data/lib/epos_now_sandbox_simulator/data/cafe_bakery/tenders.json +8 -0
  13. data/lib/epos_now_sandbox_simulator/data/restaurant/categories.json +9 -0
  14. data/lib/epos_now_sandbox_simulator/data/restaurant/items.json +29 -0
  15. data/lib/epos_now_sandbox_simulator/data/restaurant/tenders.json +9 -0
  16. data/lib/epos_now_sandbox_simulator/data/retail_general/categories.json +9 -0
  17. data/lib/epos_now_sandbox_simulator/data/retail_general/items.json +17 -0
  18. data/lib/epos_now_sandbox_simulator/data/retail_general/tenders.json +8 -0
  19. data/lib/epos_now_sandbox_simulator/database.rb +136 -0
  20. data/lib/epos_now_sandbox_simulator/db/factories/api_requests.rb +13 -0
  21. data/lib/epos_now_sandbox_simulator/db/factories/business_types.rb +34 -0
  22. data/lib/epos_now_sandbox_simulator/db/factories/categories.rb +10 -0
  23. data/lib/epos_now_sandbox_simulator/db/factories/items.rb +12 -0
  24. data/lib/epos_now_sandbox_simulator/db/factories/simulated_orders.rb +25 -0
  25. data/lib/epos_now_sandbox_simulator/db/factories/simulated_payments.rb +14 -0
  26. data/lib/epos_now_sandbox_simulator/db/migrate/20260312000001_enable_pgcrypto.rb +7 -0
  27. data/lib/epos_now_sandbox_simulator/db/migrate/20260312000002_create_business_types.rb +16 -0
  28. data/lib/epos_now_sandbox_simulator/db/migrate/20260312000003_create_categories.rb +16 -0
  29. data/lib/epos_now_sandbox_simulator/db/migrate/20260312000004_create_items.rb +19 -0
  30. data/lib/epos_now_sandbox_simulator/db/migrate/20260312000005_create_simulated_orders.rb +27 -0
  31. data/lib/epos_now_sandbox_simulator/db/migrate/20260312000006_create_simulated_payments.rb +22 -0
  32. data/lib/epos_now_sandbox_simulator/db/migrate/20260312000007_create_api_requests.rb +22 -0
  33. data/lib/epos_now_sandbox_simulator/db/migrate/20260312000008_create_daily_summaries.rb +21 -0
  34. data/lib/epos_now_sandbox_simulator/generators/data_loader.rb +100 -0
  35. data/lib/epos_now_sandbox_simulator/generators/entity_generator.rb +103 -0
  36. data/lib/epos_now_sandbox_simulator/generators/order_generator.rb +336 -0
  37. data/lib/epos_now_sandbox_simulator/models/api_request.rb +16 -0
  38. data/lib/epos_now_sandbox_simulator/models/business_type.rb +16 -0
  39. data/lib/epos_now_sandbox_simulator/models/category.rb +18 -0
  40. data/lib/epos_now_sandbox_simulator/models/daily_summary.rb +43 -0
  41. data/lib/epos_now_sandbox_simulator/models/item.rb +20 -0
  42. data/lib/epos_now_sandbox_simulator/models/simulated_order.rb +21 -0
  43. data/lib/epos_now_sandbox_simulator/models/simulated_payment.rb +17 -0
  44. data/lib/epos_now_sandbox_simulator/seeder.rb +119 -0
  45. data/lib/epos_now_sandbox_simulator/services/base_service.rb +248 -0
  46. data/lib/epos_now_sandbox_simulator/services/epos_now/inventory_service.rb +178 -0
  47. data/lib/epos_now_sandbox_simulator/services/epos_now/services_manager.rb +56 -0
  48. data/lib/epos_now_sandbox_simulator/services/epos_now/tax_service.rb +45 -0
  49. data/lib/epos_now_sandbox_simulator/services/epos_now/tender_service.rb +90 -0
  50. data/lib/epos_now_sandbox_simulator/services/epos_now/transaction_service.rb +171 -0
  51. data/lib/epos_now_sandbox_simulator.rb +49 -0
  52. 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