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
data/bin/simulate
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "epos_now_sandbox_simulator"
|
|
6
|
+
require "thor"
|
|
7
|
+
|
|
8
|
+
module EposNowSandboxSimulator
|
|
9
|
+
class CLI < Thor
|
|
10
|
+
class_option :verbose, type: :boolean, aliases: "-v", desc: "Enable verbose logging"
|
|
11
|
+
class_option :merchant_index, type: :numeric, aliases: "-i", desc: "Device index (0-based, from .env.json)"
|
|
12
|
+
|
|
13
|
+
desc "merchants", "List available devices from .env.json"
|
|
14
|
+
def merchants
|
|
15
|
+
puts "Epos Now Sandbox Simulator - Available Devices"
|
|
16
|
+
puts "=" * 55
|
|
17
|
+
|
|
18
|
+
config = EposNowSandboxSimulator.configuration
|
|
19
|
+
devices = config.available_merchants
|
|
20
|
+
|
|
21
|
+
if devices.empty?
|
|
22
|
+
puts "No devices found in .env.json"
|
|
23
|
+
return
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
puts "\n#{"Index".ljust(6)} #{"Device Name".ljust(30)} Credentials"
|
|
27
|
+
puts "-" * 55
|
|
28
|
+
|
|
29
|
+
devices.each_with_index do |d, idx|
|
|
30
|
+
creds = d[:has_credentials] ? "OK" : "MISSING"
|
|
31
|
+
puts "#{idx.to_s.ljust(6)} #{(d[:name] || "unnamed").ljust(30)} #{creds}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
puts "\nUse: simulate <command> -i <index>"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
desc "setup", "Set up POS entities (categories, products, tender types)"
|
|
38
|
+
option :business_type, type: :string, default: "restaurant",
|
|
39
|
+
desc: "Business type (restaurant, cafe_bakery, bar_nightclub, retail_general)"
|
|
40
|
+
def setup
|
|
41
|
+
configure_logging
|
|
42
|
+
|
|
43
|
+
puts "Epos Now Sandbox Simulator - Entity Setup"
|
|
44
|
+
puts "=" * 50
|
|
45
|
+
|
|
46
|
+
generator = Generators::EntityGenerator.new(business_type: options[:business_type].to_sym)
|
|
47
|
+
stats = generator.setup_all
|
|
48
|
+
|
|
49
|
+
puts "\nSetup complete!"
|
|
50
|
+
puts " Tender types: #{stats[:tender_types]}"
|
|
51
|
+
puts " Categories: #{stats[:categories]}"
|
|
52
|
+
puts " Products: #{stats[:products]}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
desc "generate", "Generate orders for today"
|
|
56
|
+
option :count, type: :numeric, aliases: "-n", desc: "Number of orders to generate"
|
|
57
|
+
option :refund_percentage, type: :numeric, aliases: "-r", default: 5,
|
|
58
|
+
desc: "Percentage of orders to refund (0-100, default 5)"
|
|
59
|
+
def generate
|
|
60
|
+
configure_logging
|
|
61
|
+
|
|
62
|
+
puts "Epos Now Sandbox Simulator - Order Generation"
|
|
63
|
+
puts "=" * 50
|
|
64
|
+
|
|
65
|
+
generator = Generators::OrderGenerator.new(refund_percentage: options[:refund_percentage])
|
|
66
|
+
orders = generator.generate_today(count: options[:count])
|
|
67
|
+
|
|
68
|
+
puts "\nGenerated #{orders.size} orders!"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
desc "day", "Generate a realistic full day of operations"
|
|
72
|
+
option :multiplier, type: :numeric, aliases: "-x", default: 1.0, desc: "Order multiplier (0.5 = slow, 2.0 = busy)"
|
|
73
|
+
option :refund_percentage, type: :numeric, aliases: "-r", default: 5,
|
|
74
|
+
desc: "Percentage of orders to refund (0-100, default 5)"
|
|
75
|
+
def day
|
|
76
|
+
configure_logging
|
|
77
|
+
|
|
78
|
+
puts "Epos Now Sandbox Simulator - Full Day Generation"
|
|
79
|
+
puts "=" * 50
|
|
80
|
+
|
|
81
|
+
generator = Generators::OrderGenerator.new(refund_percentage: options[:refund_percentage])
|
|
82
|
+
orders = generator.generate_realistic_day(multiplier: options[:multiplier])
|
|
83
|
+
|
|
84
|
+
puts "\nGenerated #{orders.size} orders!"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
desc "rush", "Generate a meal period rush"
|
|
88
|
+
option :period, type: :string, aliases: "-p", default: "dinner", desc: "Meal period (breakfast, lunch, happy_hour, dinner, late_night)"
|
|
89
|
+
option :count, type: :numeric, aliases: "-n", default: 15, desc: "Number of orders"
|
|
90
|
+
def rush
|
|
91
|
+
configure_logging
|
|
92
|
+
|
|
93
|
+
period = options[:period].to_sym
|
|
94
|
+
count = options[:count]
|
|
95
|
+
|
|
96
|
+
puts "Epos Now Sandbox Simulator - #{period.to_s.upcase} Rush"
|
|
97
|
+
puts "=" * 50
|
|
98
|
+
|
|
99
|
+
generator = Generators::OrderGenerator.new
|
|
100
|
+
orders = generator.generate_rush(period: period, count: count)
|
|
101
|
+
|
|
102
|
+
puts "\nGenerated #{orders.size} orders!"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
desc "full", "Full setup + order generation"
|
|
106
|
+
option :business_type, type: :string, default: "restaurant", desc: "Business type"
|
|
107
|
+
option :multiplier, type: :numeric, aliases: "-x", default: 1.0, desc: "Order multiplier"
|
|
108
|
+
option :refund_percentage, type: :numeric, aliases: "-r", default: 5
|
|
109
|
+
def full
|
|
110
|
+
configure_logging
|
|
111
|
+
|
|
112
|
+
puts "Epos Now Sandbox Simulator - Full Setup + Generation"
|
|
113
|
+
puts "=" * 50
|
|
114
|
+
|
|
115
|
+
# Setup
|
|
116
|
+
generator = Generators::EntityGenerator.new(business_type: options[:business_type].to_sym)
|
|
117
|
+
stats = generator.setup_all
|
|
118
|
+
puts "Setup: #{stats[:categories]} categories, #{stats[:products]} products"
|
|
119
|
+
|
|
120
|
+
# Generate
|
|
121
|
+
order_gen = Generators::OrderGenerator.new(refund_percentage: options[:refund_percentage])
|
|
122
|
+
orders = order_gen.generate_realistic_day(multiplier: options[:multiplier])
|
|
123
|
+
puts "\nGenerated #{orders.size} orders!"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
desc "status", "Show current entity counts"
|
|
127
|
+
def status
|
|
128
|
+
configure_logging
|
|
129
|
+
|
|
130
|
+
puts "Epos Now Sandbox Simulator - Status"
|
|
131
|
+
puts "=" * 50
|
|
132
|
+
|
|
133
|
+
services = Services::EposNow::ServicesManager.new
|
|
134
|
+
|
|
135
|
+
categories = services.inventory.fetch_categories
|
|
136
|
+
products = services.inventory.fetch_products
|
|
137
|
+
tender_types = services.tender.fetch_tender_types
|
|
138
|
+
|
|
139
|
+
puts "\nEpos Now API:"
|
|
140
|
+
puts " Categories: #{categories.size}"
|
|
141
|
+
puts " Products: #{products.size}"
|
|
142
|
+
puts " Tender Types: #{tender_types.size}"
|
|
143
|
+
|
|
144
|
+
return unless Database.connected?
|
|
145
|
+
|
|
146
|
+
puts "\nLocal Database:"
|
|
147
|
+
puts " Orders: #{Models::SimulatedOrder.count}"
|
|
148
|
+
puts " Payments: #{Models::SimulatedPayment.count}"
|
|
149
|
+
puts " API Logs: #{Models::ApiRequest.count}"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
desc "summary", "Show daily summary"
|
|
153
|
+
option :date, type: :string, aliases: "-d", desc: "Date (YYYY-MM-DD, default today)"
|
|
154
|
+
def summary
|
|
155
|
+
configure_logging
|
|
156
|
+
connect_db
|
|
157
|
+
|
|
158
|
+
date = options[:date] ? Date.parse(options[:date]) : Date.today
|
|
159
|
+
|
|
160
|
+
puts "Epos Now Sandbox Simulator - Daily Summary (#{date})"
|
|
161
|
+
puts "=" * 50
|
|
162
|
+
|
|
163
|
+
summary = Models::DailySummary.find_by(summary_date: date)
|
|
164
|
+
|
|
165
|
+
unless summary
|
|
166
|
+
puts "No summary found for #{date}. Generating..."
|
|
167
|
+
summary = Models::DailySummary.generate_for!(date)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
puts "\n Orders: #{summary.order_count}"
|
|
171
|
+
puts " Payments: #{summary.payment_count}"
|
|
172
|
+
puts " Refunds: #{summary.refund_count}"
|
|
173
|
+
puts " Revenue: $#{format("%.2f", summary.total_revenue / 100.0)}"
|
|
174
|
+
puts " Tax: $#{format("%.2f", summary.total_tax / 100.0)}"
|
|
175
|
+
puts " Tips: $#{format("%.2f", summary.total_tips / 100.0)}"
|
|
176
|
+
puts " Discounts: $#{format("%.2f", summary.total_discounts / 100.0)}"
|
|
177
|
+
|
|
178
|
+
return unless summary.breakdown.present?
|
|
179
|
+
|
|
180
|
+
puts "\n Breakdown:"
|
|
181
|
+
summary.breakdown.each do |key, values|
|
|
182
|
+
puts " #{key}: #{values}"
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
desc "orders", "List recent orders"
|
|
187
|
+
option :limit, type: :numeric, aliases: "-l", default: 20, desc: "Number of orders to show"
|
|
188
|
+
def orders
|
|
189
|
+
configure_logging
|
|
190
|
+
connect_db
|
|
191
|
+
|
|
192
|
+
puts "Epos Now Sandbox Simulator - Recent Orders"
|
|
193
|
+
puts "=" * 70
|
|
194
|
+
|
|
195
|
+
records = Models::SimulatedOrder.order(created_at: :desc).limit(options[:limit])
|
|
196
|
+
|
|
197
|
+
puts "\n#{"ID".ljust(8)} #{"Txn ID".ljust(10)} #{"Status".ljust(10)} #{"Total".ljust(10)} #{"Period".ljust(12)} Date"
|
|
198
|
+
puts "-" * 70
|
|
199
|
+
|
|
200
|
+
records.each do |o|
|
|
201
|
+
total = "$#{format("%.2f", o.total / 100.0)}"
|
|
202
|
+
period = (o.meal_period || "-").ljust(12)
|
|
203
|
+
txn_id = o.epos_now_transaction_id.to_s.ljust(10)
|
|
204
|
+
puts "#{o.id[0..6].ljust(8)} #{txn_id} #{o.status.ljust(10)} #{total.ljust(10)} #{period} #{o.business_date}"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
puts "\nTotal: #{records.size} orders shown"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
desc "audit", "Show recent API requests"
|
|
211
|
+
option :limit, type: :numeric, aliases: "-l", default: 20, desc: "Number of requests to show"
|
|
212
|
+
def audit
|
|
213
|
+
configure_logging
|
|
214
|
+
connect_db
|
|
215
|
+
|
|
216
|
+
puts "Epos Now Sandbox Simulator - API Audit Log"
|
|
217
|
+
puts "=" * 80
|
|
218
|
+
|
|
219
|
+
records = Models::ApiRequest.recent.limit(options[:limit])
|
|
220
|
+
|
|
221
|
+
puts "\n#{"Method".ljust(7)} #{"Status".ljust(7)} #{"Duration".ljust(10)} #{"Resource".ljust(20)} URL"
|
|
222
|
+
puts "-" * 80
|
|
223
|
+
|
|
224
|
+
records.each do |r|
|
|
225
|
+
short_url = r.url.sub("https://api.eposnowhq.com/", "")
|
|
226
|
+
status = (r.response_status || "-").to_s.ljust(7)
|
|
227
|
+
duration = "#{r.duration_ms}ms".ljust(10)
|
|
228
|
+
resource = (r.resource_type || "-").ljust(20)
|
|
229
|
+
puts "#{r.http_method.ljust(7)} #{status} #{duration} #{resource} #{short_url}"
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
desc "business_types", "List available business types"
|
|
234
|
+
def business_types
|
|
235
|
+
puts "Epos Now Sandbox Simulator - Business Types"
|
|
236
|
+
puts "=" * 50
|
|
237
|
+
|
|
238
|
+
Generators::DataLoader::BUSINESS_TYPES.each do |bt|
|
|
239
|
+
loader = Generators::DataLoader.new(business_type: bt)
|
|
240
|
+
cats = loader.load_categories
|
|
241
|
+
items = loader.load_items
|
|
242
|
+
puts "\n #{bt}: #{cats.size} categories, #{items.size} items"
|
|
243
|
+
cats.each { |c| puts " - #{c["name"]}" }
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# ==========================================
|
|
248
|
+
# DATABASE MANAGEMENT
|
|
249
|
+
# ==========================================
|
|
250
|
+
|
|
251
|
+
desc "db SUBCOMMAND", "Database management (create, migrate, seed, reset)"
|
|
252
|
+
def db(subcommand)
|
|
253
|
+
configure_logging
|
|
254
|
+
|
|
255
|
+
case subcommand
|
|
256
|
+
when "create"
|
|
257
|
+
url = Database.database_url
|
|
258
|
+
Database.create!(url)
|
|
259
|
+
puts "Database created"
|
|
260
|
+
when "migrate"
|
|
261
|
+
connect_db
|
|
262
|
+
Database.migrate!
|
|
263
|
+
puts "Migrations complete"
|
|
264
|
+
when "seed"
|
|
265
|
+
connect_db
|
|
266
|
+
Database.seed!
|
|
267
|
+
puts "Seeding complete"
|
|
268
|
+
when "reset"
|
|
269
|
+
puts "This will DROP and recreate the database. Are you sure? (yes/no)"
|
|
270
|
+
confirm = $stdin.gets.chomp
|
|
271
|
+
if confirm == "yes"
|
|
272
|
+
url = Database.database_url
|
|
273
|
+
Database.drop!(url)
|
|
274
|
+
Database.create!(url)
|
|
275
|
+
Database.connect!(url)
|
|
276
|
+
Database.migrate!
|
|
277
|
+
Database.seed!
|
|
278
|
+
puts "Database reset complete"
|
|
279
|
+
else
|
|
280
|
+
puts "Cancelled"
|
|
281
|
+
end
|
|
282
|
+
else
|
|
283
|
+
puts "Unknown subcommand: #{subcommand}. Use: create, migrate, seed, reset"
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
private
|
|
288
|
+
|
|
289
|
+
def configure_logging
|
|
290
|
+
EposNowSandboxSimulator.configuration.log_level = Logger::DEBUG if options[:verbose]
|
|
291
|
+
|
|
292
|
+
# Load specific merchant if index given
|
|
293
|
+
return unless options[:merchant_index]
|
|
294
|
+
|
|
295
|
+
EposNowSandboxSimulator.configuration.load_merchant(index: options[:merchant_index])
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def connect_db
|
|
299
|
+
url = Database.database_url
|
|
300
|
+
Database.connect!(url)
|
|
301
|
+
rescue StandardError => e
|
|
302
|
+
puts "Database not available: #{e.message}"
|
|
303
|
+
puts "Some commands require a database. Run: simulate db create && simulate db migrate"
|
|
304
|
+
exit 1
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
EposNowSandboxSimulator::CLI.start(ARGV)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EposNowSandboxSimulator
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :api_key, :api_secret, :environment, :log_level, :tax_rate, :business_type,
|
|
6
|
+
:device_name, :merchant_timezone
|
|
7
|
+
|
|
8
|
+
# Default timezone if not configured
|
|
9
|
+
DEFAULT_TIMEZONE = "America/Los_Angeles"
|
|
10
|
+
|
|
11
|
+
# Path to merchants JSON file
|
|
12
|
+
MERCHANTS_FILE = File.join(File.dirname(__FILE__), "..", "..", ".env.json")
|
|
13
|
+
|
|
14
|
+
# Epos Now API base URL
|
|
15
|
+
DEFAULT_API_URL = "https://api.eposnowhq.com"
|
|
16
|
+
|
|
17
|
+
def initialize
|
|
18
|
+
@api_key = ENV.fetch("EPOS_NOW_API_KEY", nil)
|
|
19
|
+
@api_secret = ENV.fetch("EPOS_NOW_API_SECRET", nil)
|
|
20
|
+
@device_name = ENV.fetch("EPOS_NOW_DEVICE_NAME", nil)
|
|
21
|
+
@environment = normalize_url(ENV.fetch("EPOS_NOW_API_URL", DEFAULT_API_URL))
|
|
22
|
+
@log_level = parse_log_level(ENV.fetch("LOG_LEVEL", "INFO"))
|
|
23
|
+
@tax_rate = ENV.fetch("TAX_RATE", "8.25").to_f
|
|
24
|
+
@business_type = ENV.fetch("BUSINESS_TYPE", "restaurant").to_sym
|
|
25
|
+
@merchant_timezone = ENV.fetch("MERCHANT_TIMEZONE", DEFAULT_TIMEZONE)
|
|
26
|
+
|
|
27
|
+
# Load from .env.json if api_key not set in ENV
|
|
28
|
+
load_from_merchants_file if @api_key.nil? || @api_key.empty?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Build the Basic Auth token from API Key + Secret
|
|
32
|
+
# Epos Now: Base64(api_key:api_secret)
|
|
33
|
+
#
|
|
34
|
+
# @return [String] Base64-encoded credentials
|
|
35
|
+
def auth_token
|
|
36
|
+
require "base64"
|
|
37
|
+
Base64.strict_encode64("#{api_key}:#{api_secret}")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Load configuration for a specific device from .env.json
|
|
41
|
+
#
|
|
42
|
+
# @param device_name [String, nil] Device name to load
|
|
43
|
+
# @param index [Integer, nil] Index of device in the list (0-based)
|
|
44
|
+
# @return [self]
|
|
45
|
+
def load_merchant(device_name: nil, index: nil)
|
|
46
|
+
merchants = load_merchants_file
|
|
47
|
+
return self if merchants.empty?
|
|
48
|
+
|
|
49
|
+
merchant = if device_name
|
|
50
|
+
merchants.find { |m| m["EPOS_NOW_DEVICE_NAME"] == device_name }
|
|
51
|
+
elsif index
|
|
52
|
+
merchants[index]
|
|
53
|
+
else
|
|
54
|
+
merchants.first
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
if merchant
|
|
58
|
+
apply_merchant_config(merchant)
|
|
59
|
+
logger.info "Loaded device: #{@device_name}"
|
|
60
|
+
else
|
|
61
|
+
logger.warn "Device not found: #{device_name || "index #{index}"}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
self
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# List all available devices from .env.json
|
|
68
|
+
#
|
|
69
|
+
# @return [Array<Hash>] Array of device configs
|
|
70
|
+
def available_merchants
|
|
71
|
+
load_merchants_file.map do |m|
|
|
72
|
+
{
|
|
73
|
+
name: m["EPOS_NOW_DEVICE_NAME"],
|
|
74
|
+
has_credentials: !m["EPOS_NOW_API_KEY"].to_s.empty? && !m["EPOS_NOW_API_SECRET"].to_s.empty?
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def validate!
|
|
80
|
+
raise ConfigurationError, "EPOS_NOW_API_KEY is required" if api_key.nil? || api_key.empty?
|
|
81
|
+
raise ConfigurationError, "EPOS_NOW_API_SECRET is required" if api_secret.nil? || api_secret.empty?
|
|
82
|
+
|
|
83
|
+
true
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def logger
|
|
87
|
+
@logger ||= Logger.new($stdout).tap do |log|
|
|
88
|
+
log.level = @log_level
|
|
89
|
+
log.formatter = proc do |severity, datetime, _progname, msg|
|
|
90
|
+
timestamp = datetime.strftime("%Y-%m-%d %H:%M:%S")
|
|
91
|
+
"[#{timestamp}] #{severity.ljust(5)} | #{msg}\n"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Get current time in merchant's timezone
|
|
97
|
+
# @return [Time] Current time in merchant timezone
|
|
98
|
+
def merchant_time_now
|
|
99
|
+
require "time"
|
|
100
|
+
begin
|
|
101
|
+
require "tzinfo"
|
|
102
|
+
TZInfo::Timezone.get(merchant_timezone).now
|
|
103
|
+
rescue LoadError
|
|
104
|
+
old_tz = ENV.fetch("TZ", nil)
|
|
105
|
+
ENV["TZ"] = merchant_timezone
|
|
106
|
+
time = Time.now
|
|
107
|
+
ENV["TZ"] = old_tz
|
|
108
|
+
time
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Get today's date in merchant's timezone
|
|
113
|
+
# @return [Date] Today's date in merchant timezone
|
|
114
|
+
def merchant_date_today
|
|
115
|
+
merchant_time_now.to_date
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def load_from_merchants_file
|
|
121
|
+
merchants = load_merchants_file
|
|
122
|
+
return if merchants.empty?
|
|
123
|
+
|
|
124
|
+
apply_merchant_config(merchants.first)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Parse .env.json, supporting both array and object formats
|
|
128
|
+
def load_merchants_file
|
|
129
|
+
return [] unless File.exist?(MERCHANTS_FILE)
|
|
130
|
+
|
|
131
|
+
data = JSON.parse(File.read(MERCHANTS_FILE))
|
|
132
|
+
return data if data.is_a?(Array)
|
|
133
|
+
|
|
134
|
+
data.fetch("merchants", [])
|
|
135
|
+
rescue JSON::ParserError => e
|
|
136
|
+
warn "Failed to parse #{MERCHANTS_FILE}: #{e.message}"
|
|
137
|
+
[]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Read the top-level DATABASE_URL from .env.json
|
|
141
|
+
def self.database_url_from_file
|
|
142
|
+
return nil unless File.exist?(MERCHANTS_FILE)
|
|
143
|
+
|
|
144
|
+
data = JSON.parse(File.read(MERCHANTS_FILE))
|
|
145
|
+
return nil if data.is_a?(Array)
|
|
146
|
+
|
|
147
|
+
data["DATABASE_URL"]
|
|
148
|
+
rescue JSON::ParserError
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def apply_merchant_config(merchant)
|
|
153
|
+
@api_key = merchant["EPOS_NOW_API_KEY"] unless merchant["EPOS_NOW_API_KEY"].to_s.empty?
|
|
154
|
+
@api_secret = merchant["EPOS_NOW_API_SECRET"] unless merchant["EPOS_NOW_API_SECRET"].to_s.empty?
|
|
155
|
+
@device_name = merchant["EPOS_NOW_DEVICE_NAME"] unless merchant["EPOS_NOW_DEVICE_NAME"].to_s.empty?
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def normalize_url(url)
|
|
159
|
+
url = url.strip
|
|
160
|
+
url.end_with?("/") ? url : "#{url}/"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def parse_log_level(level)
|
|
164
|
+
{
|
|
165
|
+
"DEBUG" => Logger::DEBUG,
|
|
166
|
+
"INFO" => Logger::INFO,
|
|
167
|
+
"WARN" => Logger::WARN,
|
|
168
|
+
"ERROR" => Logger::ERROR,
|
|
169
|
+
"FATAL" => Logger::FATAL
|
|
170
|
+
}.fetch(level.to_s.upcase, Logger::INFO)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"categories": [
|
|
3
|
+
{ "name": "Draft Beer", "sort_order": 1, "description": "Beers on tap" },
|
|
4
|
+
{ "name": "Bottled Beer", "sort_order": 2, "description": "Bottled and canned beers" },
|
|
5
|
+
{ "name": "Cocktails", "sort_order": 3, "description": "Mixed drinks" },
|
|
6
|
+
{ "name": "Wine", "sort_order": 4, "description": "Wine by the glass" },
|
|
7
|
+
{ "name": "Bar Snacks", "sort_order": 5, "description": "Bar food and snacks" }
|
|
8
|
+
]
|
|
9
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"items": [
|
|
3
|
+
{ "name": "IPA Pint", "price": 7.50, "category": "Draft Beer", "sku": "BAR-DRF-001" },
|
|
4
|
+
{ "name": "Lager Pint", "price": 6.50, "category": "Draft Beer", "sku": "BAR-DRF-002" },
|
|
5
|
+
{ "name": "Stout Pint", "price": 7.00, "category": "Draft Beer", "sku": "BAR-DRF-003" },
|
|
6
|
+
{ "name": "Wheat Beer", "price": 7.50, "category": "Draft Beer", "sku": "BAR-DRF-004" },
|
|
7
|
+
{ "name": "Pale Ale", "price": 7.00, "category": "Draft Beer", "sku": "BAR-DRF-005" },
|
|
8
|
+
{ "name": "Corona", "price": 6.00, "category": "Bottled Beer", "sku": "BAR-BTL-001" },
|
|
9
|
+
{ "name": "Heineken", "price": 5.50, "category": "Bottled Beer", "sku": "BAR-BTL-002" },
|
|
10
|
+
{ "name": "Budweiser", "price": 5.00, "category": "Bottled Beer", "sku": "BAR-BTL-003" },
|
|
11
|
+
{ "name": "Seltzer", "price": 5.50, "category": "Bottled Beer", "sku": "BAR-BTL-004" },
|
|
12
|
+
{ "name": "Margarita", "price": 12.00, "category": "Cocktails", "sku": "BAR-COK-001" },
|
|
13
|
+
{ "name": "Old Fashioned", "price": 14.00, "category": "Cocktails", "sku": "BAR-COK-002" },
|
|
14
|
+
{ "name": "Mojito", "price": 12.00, "category": "Cocktails", "sku": "BAR-COK-003" },
|
|
15
|
+
{ "name": "Espresso Martini", "price": 13.00, "category": "Cocktails", "sku": "BAR-COK-004" },
|
|
16
|
+
{ "name": "Long Island", "price": 14.00, "category": "Cocktails", "sku": "BAR-COK-005" },
|
|
17
|
+
{ "name": "House Red Wine", "price": 9.00, "category": "Wine", "sku": "BAR-WIN-001" },
|
|
18
|
+
{ "name": "House White Wine", "price": 9.00, "category": "Wine", "sku": "BAR-WIN-002" },
|
|
19
|
+
{ "name": "Prosecco", "price": 10.00, "category": "Wine", "sku": "BAR-WIN-003" },
|
|
20
|
+
{ "name": "Chicken Wings", "price": 10.99, "category": "Bar Snacks", "sku": "BAR-SNK-001" },
|
|
21
|
+
{ "name": "Loaded Fries", "price": 8.99, "category": "Bar Snacks", "sku": "BAR-SNK-002" },
|
|
22
|
+
{ "name": "Nachos", "price": 11.99, "category": "Bar Snacks", "sku": "BAR-SNK-003" },
|
|
23
|
+
{ "name": "Slider Trio", "price": 12.99, "category": "Bar Snacks", "sku": "BAR-SNK-004" },
|
|
24
|
+
{ "name": "Mixed Nuts", "price": 5.99, "category": "Bar Snacks", "sku": "BAR-SNK-005" }
|
|
25
|
+
]
|
|
26
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"tenders": [
|
|
3
|
+
{ "name": "Cash", "description": "Cash payment", "weight": 20 },
|
|
4
|
+
{ "name": "Credit Card", "description": "Credit card payment", "weight": 55 },
|
|
5
|
+
{ "name": "Debit Card", "description": "Debit card payment", "weight": 20 },
|
|
6
|
+
{ "name": "Gift Card", "description": "Gift card payment", "weight": 5 }
|
|
7
|
+
]
|
|
8
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"categories": [
|
|
3
|
+
{ "name": "Hot Drinks", "sort_order": 1, "description": "Coffee, tea, and hot beverages" },
|
|
4
|
+
{ "name": "Cold Drinks", "sort_order": 2, "description": "Iced and cold beverages" },
|
|
5
|
+
{ "name": "Pastries", "sort_order": 3, "description": "Fresh baked pastries" },
|
|
6
|
+
{ "name": "Sandwiches", "sort_order": 4, "description": "Fresh sandwiches and wraps" },
|
|
7
|
+
{ "name": "Cakes", "sort_order": 5, "description": "Cakes and slices" }
|
|
8
|
+
]
|
|
9
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"items": [
|
|
3
|
+
{ "name": "Espresso", "price": 3.50, "category": "Hot Drinks", "sku": "CAFE-HOT-001" },
|
|
4
|
+
{ "name": "Cappuccino", "price": 4.50, "category": "Hot Drinks", "sku": "CAFE-HOT-002" },
|
|
5
|
+
{ "name": "Latte", "price": 4.75, "category": "Hot Drinks", "sku": "CAFE-HOT-003" },
|
|
6
|
+
{ "name": "Hot Chocolate", "price": 4.25, "category": "Hot Drinks", "sku": "CAFE-HOT-004" },
|
|
7
|
+
{ "name": "English Breakfast Tea", "price": 3.25, "category": "Hot Drinks", "sku": "CAFE-HOT-005" },
|
|
8
|
+
{ "name": "Iced Latte", "price": 5.25, "category": "Cold Drinks", "sku": "CAFE-CLD-001" },
|
|
9
|
+
{ "name": "Iced Americano", "price": 4.75, "category": "Cold Drinks", "sku": "CAFE-CLD-002" },
|
|
10
|
+
{ "name": "Smoothie", "price": 5.99, "category": "Cold Drinks", "sku": "CAFE-CLD-003" },
|
|
11
|
+
{ "name": "Fresh Orange Juice", "price": 4.50, "category": "Cold Drinks", "sku": "CAFE-CLD-004" },
|
|
12
|
+
{ "name": "Croissant", "price": 3.50, "category": "Pastries", "sku": "CAFE-PAS-001" },
|
|
13
|
+
{ "name": "Pain au Chocolat", "price": 3.75, "category": "Pastries", "sku": "CAFE-PAS-002" },
|
|
14
|
+
{ "name": "Blueberry Muffin", "price": 3.99, "category": "Pastries", "sku": "CAFE-PAS-003" },
|
|
15
|
+
{ "name": "Cinnamon Roll", "price": 4.25, "category": "Pastries", "sku": "CAFE-PAS-004" },
|
|
16
|
+
{ "name": "Scone", "price": 3.25, "category": "Pastries", "sku": "CAFE-PAS-005" },
|
|
17
|
+
{ "name": "Club Sandwich", "price": 8.99, "category": "Sandwiches", "sku": "CAFE-SAN-001" },
|
|
18
|
+
{ "name": "BLT", "price": 7.99, "category": "Sandwiches", "sku": "CAFE-SAN-002" },
|
|
19
|
+
{ "name": "Avocado Toast", "price": 9.50, "category": "Sandwiches", "sku": "CAFE-SAN-003" },
|
|
20
|
+
{ "name": "Panini", "price": 8.50, "category": "Sandwiches", "sku": "CAFE-SAN-004" },
|
|
21
|
+
{ "name": "Chicken Wrap", "price": 8.99, "category": "Sandwiches", "sku": "CAFE-SAN-005" },
|
|
22
|
+
{ "name": "Carrot Cake", "price": 5.50, "category": "Cakes", "sku": "CAFE-CAK-001" },
|
|
23
|
+
{ "name": "Victoria Sponge", "price": 5.25, "category": "Cakes", "sku": "CAFE-CAK-002" },
|
|
24
|
+
{ "name": "Brownie", "price": 4.50, "category": "Cakes", "sku": "CAFE-CAK-003" },
|
|
25
|
+
{ "name": "Lemon Drizzle", "price": 4.99, "category": "Cakes", "sku": "CAFE-CAK-004" },
|
|
26
|
+
{ "name": "Red Velvet Slice", "price": 5.75, "category": "Cakes", "sku": "CAFE-CAK-005" }
|
|
27
|
+
]
|
|
28
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"tenders": [
|
|
3
|
+
{ "name": "Cash", "description": "Cash payment", "weight": 30 },
|
|
4
|
+
{ "name": "Credit Card", "description": "Credit card payment", "weight": 50 },
|
|
5
|
+
{ "name": "Debit Card", "description": "Debit card payment", "weight": 15 },
|
|
6
|
+
{ "name": "Gift Card", "description": "Gift card payment", "weight": 5 }
|
|
7
|
+
]
|
|
8
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"categories": [
|
|
3
|
+
{ "name": "Appetizers", "sort_order": 1, "description": "Starters and small plates" },
|
|
4
|
+
{ "name": "Entrees", "sort_order": 2, "description": "Main course dishes" },
|
|
5
|
+
{ "name": "Sides", "sort_order": 3, "description": "Side dishes" },
|
|
6
|
+
{ "name": "Drinks", "sort_order": 4, "description": "Beverages" },
|
|
7
|
+
{ "name": "Desserts", "sort_order": 5, "description": "Sweets and treats" }
|
|
8
|
+
]
|
|
9
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"items": [
|
|
3
|
+
{ "name": "Buffalo Wings", "price": 12.99, "category": "Appetizers", "sku": "REST-APP-001" },
|
|
4
|
+
{ "name": "Mozzarella Sticks", "price": 9.99, "category": "Appetizers", "sku": "REST-APP-002" },
|
|
5
|
+
{ "name": "Loaded Nachos", "price": 13.99, "category": "Appetizers", "sku": "REST-APP-003" },
|
|
6
|
+
{ "name": "Bruschetta", "price": 10.99, "category": "Appetizers", "sku": "REST-APP-004" },
|
|
7
|
+
{ "name": "Calamari", "price": 11.99, "category": "Appetizers", "sku": "REST-APP-005" },
|
|
8
|
+
{ "name": "Grilled Salmon", "price": 24.99, "category": "Entrees", "sku": "REST-ENT-001" },
|
|
9
|
+
{ "name": "NY Strip Steak", "price": 29.99, "category": "Entrees", "sku": "REST-ENT-002" },
|
|
10
|
+
{ "name": "Chicken Parmesan", "price": 18.99, "category": "Entrees", "sku": "REST-ENT-003" },
|
|
11
|
+
{ "name": "Fish and Chips", "price": 16.99, "category": "Entrees", "sku": "REST-ENT-004" },
|
|
12
|
+
{ "name": "Pasta Alfredo", "price": 15.99, "category": "Entrees", "sku": "REST-ENT-005" },
|
|
13
|
+
{ "name": "BBQ Ribs", "price": 22.99, "category": "Entrees", "sku": "REST-ENT-006" },
|
|
14
|
+
{ "name": "Caesar Salad", "price": 12.99, "category": "Entrees", "sku": "REST-ENT-007" },
|
|
15
|
+
{ "name": "French Fries", "price": 5.99, "category": "Sides", "sku": "REST-SID-001" },
|
|
16
|
+
{ "name": "Coleslaw", "price": 4.99, "category": "Sides", "sku": "REST-SID-002" },
|
|
17
|
+
{ "name": "Mac and Cheese", "price": 6.99, "category": "Sides", "sku": "REST-SID-003" },
|
|
18
|
+
{ "name": "Garden Salad", "price": 5.49, "category": "Sides", "sku": "REST-SID-004" },
|
|
19
|
+
{ "name": "Onion Rings", "price": 6.49, "category": "Sides", "sku": "REST-SID-005" },
|
|
20
|
+
{ "name": "Coca-Cola", "price": 2.99, "category": "Drinks", "sku": "REST-DRK-001" },
|
|
21
|
+
{ "name": "Iced Tea", "price": 2.99, "category": "Drinks", "sku": "REST-DRK-002" },
|
|
22
|
+
{ "name": "Draft Beer", "price": 6.99, "category": "Drinks", "sku": "REST-DRK-003" },
|
|
23
|
+
{ "name": "House Wine", "price": 8.99, "category": "Drinks", "sku": "REST-DRK-004" },
|
|
24
|
+
{ "name": "Lemonade", "price": 3.49, "category": "Drinks", "sku": "REST-DRK-005" },
|
|
25
|
+
{ "name": "Chocolate Cake", "price": 8.99, "category": "Desserts", "sku": "REST-DES-001" },
|
|
26
|
+
{ "name": "Cheesecake", "price": 9.99, "category": "Desserts", "sku": "REST-DES-002" },
|
|
27
|
+
{ "name": "Ice Cream Sundae", "price": 7.99, "category": "Desserts", "sku": "REST-DES-003" }
|
|
28
|
+
]
|
|
29
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"tenders": [
|
|
3
|
+
{ "name": "Cash", "description": "Cash payment", "weight": 25 },
|
|
4
|
+
{ "name": "Credit Card", "description": "Credit card payment", "weight": 45 },
|
|
5
|
+
{ "name": "Debit Card", "description": "Debit card payment", "weight": 20 },
|
|
6
|
+
{ "name": "Gift Card", "description": "Gift card payment", "weight": 5 },
|
|
7
|
+
{ "name": "Check", "description": "Check payment", "weight": 5 }
|
|
8
|
+
]
|
|
9
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"categories": [
|
|
3
|
+
{ "name": "Electronics", "sort_order": 1, "description": "Electronic devices and accessories" },
|
|
4
|
+
{ "name": "Clothing", "sort_order": 2, "description": "Apparel and accessories" },
|
|
5
|
+
{ "name": "Home & Garden", "sort_order": 3, "description": "Home goods and garden supplies" },
|
|
6
|
+
{ "name": "Health & Beauty", "sort_order": 4, "description": "Health and beauty products" },
|
|
7
|
+
{ "name": "Groceries", "sort_order": 5, "description": "Food and grocery items" }
|
|
8
|
+
]
|
|
9
|
+
}
|