heartland_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/README.md +105 -0
  3. data/bin/simulate +364 -0
  4. data/lib/heartland_sandbox_simulator/configuration.rb +191 -0
  5. data/lib/heartland_sandbox_simulator/data/bar_nightclub/categories.json +9 -0
  6. data/lib/heartland_sandbox_simulator/data/bar_nightclub/items.json +29 -0
  7. data/lib/heartland_sandbox_simulator/data/bar_nightclub/tenders.json +11 -0
  8. data/lib/heartland_sandbox_simulator/data/cafe_bakery/categories.json +9 -0
  9. data/lib/heartland_sandbox_simulator/data/cafe_bakery/items.json +29 -0
  10. data/lib/heartland_sandbox_simulator/data/cafe_bakery/tenders.json +11 -0
  11. data/lib/heartland_sandbox_simulator/data/fine_dining/categories.json +9 -0
  12. data/lib/heartland_sandbox_simulator/data/fine_dining/items.json +29 -0
  13. data/lib/heartland_sandbox_simulator/data/fine_dining/tenders.json +11 -0
  14. data/lib/heartland_sandbox_simulator/data/pizzeria/categories.json +9 -0
  15. data/lib/heartland_sandbox_simulator/data/pizzeria/items.json +29 -0
  16. data/lib/heartland_sandbox_simulator/data/pizzeria/tenders.json +11 -0
  17. data/lib/heartland_sandbox_simulator/data/restaurant/categories.json +29 -0
  18. data/lib/heartland_sandbox_simulator/data/restaurant/items.json +29 -0
  19. data/lib/heartland_sandbox_simulator/data/restaurant/tenders.json +13 -0
  20. data/lib/heartland_sandbox_simulator/database.rb +182 -0
  21. data/lib/heartland_sandbox_simulator/db/factories/business_types.rb +100 -0
  22. data/lib/heartland_sandbox_simulator/db/factories/categories.rb +219 -0
  23. data/lib/heartland_sandbox_simulator/db/factories/items.rb +961 -0
  24. data/lib/heartland_sandbox_simulator/db/factories/simulated_orders.rb +100 -0
  25. data/lib/heartland_sandbox_simulator/db/factories/simulated_payments.rb +78 -0
  26. data/lib/heartland_sandbox_simulator/db/migrate/20260316000000_enable_pgcrypto.rb +7 -0
  27. data/lib/heartland_sandbox_simulator/db/migrate/20260316000001_create_business_types.rb +18 -0
  28. data/lib/heartland_sandbox_simulator/db/migrate/20260316000002_create_categories.rb +18 -0
  29. data/lib/heartland_sandbox_simulator/db/migrate/20260316000003_create_items.rb +23 -0
  30. data/lib/heartland_sandbox_simulator/db/migrate/20260316000004_create_simulated_orders.rb +38 -0
  31. data/lib/heartland_sandbox_simulator/db/migrate/20260316000005_create_simulated_payments.rb +26 -0
  32. data/lib/heartland_sandbox_simulator/db/migrate/20260316000006_create_api_requests.rb +27 -0
  33. data/lib/heartland_sandbox_simulator/db/migrate/20260316000007_create_daily_summaries.rb +24 -0
  34. data/lib/heartland_sandbox_simulator/generators/data_loader.rb +113 -0
  35. data/lib/heartland_sandbox_simulator/generators/entity_generator.rb +117 -0
  36. data/lib/heartland_sandbox_simulator/generators/order_generator.rb +609 -0
  37. data/lib/heartland_sandbox_simulator/models/api_request.rb +43 -0
  38. data/lib/heartland_sandbox_simulator/models/business_type.rb +23 -0
  39. data/lib/heartland_sandbox_simulator/models/category.rb +18 -0
  40. data/lib/heartland_sandbox_simulator/models/daily_summary.rb +82 -0
  41. data/lib/heartland_sandbox_simulator/models/item.rb +33 -0
  42. data/lib/heartland_sandbox_simulator/models/record.rb +14 -0
  43. data/lib/heartland_sandbox_simulator/models/simulated_order.rb +40 -0
  44. data/lib/heartland_sandbox_simulator/models/simulated_payment.rb +28 -0
  45. data/lib/heartland_sandbox_simulator/seeder.rb +152 -0
  46. data/lib/heartland_sandbox_simulator/services/base_service.rb +205 -0
  47. data/lib/heartland_sandbox_simulator/services/heartland/catalog_service.rb +122 -0
  48. data/lib/heartland_sandbox_simulator/services/heartland/location_service.rb +34 -0
  49. data/lib/heartland_sandbox_simulator/services/heartland/order_service.rb +122 -0
  50. data/lib/heartland_sandbox_simulator/services/heartland/payment_service.rb +88 -0
  51. data/lib/heartland_sandbox_simulator/services/heartland/service_manager.rb +64 -0
  52. data/lib/heartland_sandbox_simulator/version.rb +5 -0
  53. data/lib/heartland_sandbox_simulator.rb +47 -0
  54. metadata +337 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1790152b5eaa88db3747673ae9357c2372487abb074f1bc708822e3d47784318
4
+ data.tar.gz: ec539159dc162e9ada1d45512419b31dd0713ccfcfd82ea64132fdbefe84fced
5
+ SHA512:
6
+ metadata.gz: 60a9128dd0dc1d80f220190fc54e42fcd03f8856099a1158b146df83a0828efe30d894adcdb12568f247b37bc9ea33c6fa22079b30a1fd0802451d6b4464ddb8
7
+ data.tar.gz: f4cc7e3eba7f47a09c2bef7da24108fd30835b3a40a86e4c3f75419ccd11b30acf49d833150099f37563bcd152f37c70b96032afc5b19b7919db78e7d78242d2
data/README.md ADDED
@@ -0,0 +1,105 @@
1
+ # Heartland Sandbox Simulator
2
+
3
+ [![Ruby](https://img.shields.io/badge/Ruby-%3E%3D%203.0-red)](https://www.ruby-lang.org)
4
+ [![RSpec](https://img.shields.io/badge/Tests-RSpec-blue)](https://rspec.info)
5
+ [![Coverage](https://img.shields.io/badge/Coverage-100%25-brightgreen)](coverage/index.html)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
7
+
8
+ Ruby gem that simulates a **Heartland Genius Restaurant POS (HRPOS)** sandbox environment. Generates realistic restaurant orders, payments, and transaction data for testing [SalesToBooks](https://salestobooks.com) integrations.
9
+
10
+ Part of the [TheOwnerStack](https://github.com/dan1d) POS simulator ecosystem alongside `clover_sandbox_simulator`, `square_sandbox_simulator`, and `skytab_sandbox_simulator`.
11
+
12
+ ## What It Does
13
+
14
+ - Creates realistic menu categories, items, and tenders matching Heartland's data model
15
+ - Generates tickets with guest counts, revenue centers, and realistic payment mixes
16
+ - Simulates the **HRPOS API** format (Bearer token, Swagger-documented)
17
+ - **Daily summary endpoint** — pre-aggregated KPIs (sales, ticket averages, guest counts) unique to Heartland
18
+ - Persists all API calls to an audit trail (`api_requests` table)
19
+ - Supports 5 business types: restaurant, cafe/bakery, bar/nightclub, pizzeria, fine dining
20
+ - 6 revenue centers: Main Dining, Bar, Patio, Private Dining, Takeout, Catering
21
+ - 9 tender types: Cash, Visa, Mastercard, Amex, Discover, Debit, Gift Card, House Account, Check
22
+ - Runs as a standalone CLI (no Rails dependency)
23
+
24
+ ## Installation
25
+
26
+ ```bash
27
+ git clone https://github.com/dan1d/heartland_sandbox_simulator.git
28
+ cd heartland_sandbox_simulator
29
+ bundle install
30
+ ```
31
+
32
+ ## Configuration
33
+
34
+ Set environment variables or create `.env.json`:
35
+
36
+ ```json
37
+ {
38
+ "default": {
39
+ "HEARTLAND_LOCATION_ID": "LOC001",
40
+ "HEARTLAND_ACCESS_TOKEN": "your-api-key",
41
+ "HEARTLAND_ENVIRONMENT": "sandbox"
42
+ }
43
+ }
44
+ ```
45
+
46
+ ## Usage
47
+
48
+ ```bash
49
+ # Database setup
50
+ ./bin/simulate db create
51
+ ./bin/simulate db migrate
52
+ ./bin/simulate db seed
53
+
54
+ # Generate data
55
+ ./bin/simulate setup # Create categories, items, tenders
56
+ ./bin/simulate generate -n 50 # Generate 50 tickets for today
57
+ ./bin/simulate day # Full realistic day simulation
58
+
59
+ # Inspect
60
+ ./bin/simulate status # Entity counts
61
+ ./bin/simulate tickets # Recent tickets with totals
62
+ ./bin/simulate summary # Daily KPI summary (Heartland-specific)
63
+
64
+ # Reset
65
+ ./bin/simulate db reset # Drop, create, migrate, seed
66
+ ```
67
+
68
+ ## API Format
69
+
70
+ Simulates the Heartland Restaurant POS (HRPOS) API:
71
+
72
+ | Endpoint | Description |
73
+ |----------|-------------|
74
+ | `GET /v2/locations` | List merchant locations |
75
+ | `GET /v2/locations/{id}/menu` | Menu with categories, items, modifiers |
76
+ | `GET /v2/locations/{id}/tickets` | Tickets (CRUD + search) |
77
+ | `GET /v2/locations/{id}/daily-summary?date=YYYY-MM-DD` | Pre-aggregated daily KPIs |
78
+
79
+ Daily summary includes: total sales, ticket count, average ticket, guest count, average per guest, sales by category, sales by tender, total tax, total tips, total discounts.
80
+
81
+ ## Architecture
82
+
83
+ ```
84
+ lib/heartland_sandbox_simulator/
85
+ models/ # ActiveRecord models (UUID PKs, standalone)
86
+ services/heartland/ # API service classes (HTTP client + audit logging)
87
+ generators/ # EntityGenerator + OrderGenerator
88
+ data/ # JSON seed data per business type
89
+ db/migrate/ # PostgreSQL migrations
90
+ db/factories/ # FactoryBot factories
91
+ ```
92
+
93
+ ## Testing
94
+
95
+ ```bash
96
+ bundle exec rspec # Run all tests
97
+ COVERAGE=true bundle exec rspec # With SimpleCov report
98
+ bundle exec rubocop # Lint
99
+ ```
100
+
101
+ Target: **100% line + 100% branch coverage**.
102
+
103
+ ## License
104
+
105
+ [MIT](LICENSE) - TheOwnerStack LLC
data/bin/simulate ADDED
@@ -0,0 +1,364 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "heartland_sandbox_simulator"
6
+ require "thor"
7
+
8
+ module HeartlandSandboxSimulator
9
+ # Command-line interface for Heartland Sandbox Simulator
10
+ class CLI < Thor
11
+ class_option :verbose, type: :boolean, aliases: "-v", desc: "Enable verbose logging"
12
+ class_option :location, type: :string, aliases: "-l", desc: "Location ID to use (from .env.json)"
13
+ class_option :location_index, type: :numeric, aliases: "-i", desc: "Location index to use (0-based, from .env.json)"
14
+
15
+ desc "locations", "List available locations from .env.json"
16
+ def locations
17
+ puts "Heartland Sandbox Simulator - Available Locations"
18
+ puts "=" * 60
19
+
20
+ config = HeartlandSandboxSimulator.configuration
21
+ locs = config.available_locations
22
+
23
+ if locs.empty?
24
+ puts "No locations found in .env.json"
25
+ return
26
+ end
27
+
28
+ puts "\n#{"Index".ljust(6)} #{"Location ID".ljust(20)} Name"
29
+ puts "-" * 60
30
+
31
+ locs.each_with_index do |loc, idx|
32
+ puts "#{idx.to_s.ljust(6)} #{(loc[:id] || "N/A").ljust(20)} #{loc[:name] || "N/A"}"
33
+ end
34
+
35
+ puts "\nUse: simulate <command> -i <index> or -l <location_id>"
36
+ end
37
+
38
+ desc "setup", "Set up entities (categories, items)"
39
+ option :business_type, type: :string, default: "restaurant", desc: "Business type (restaurant, cafe_bakery, bar_nightclub, pizzeria, fine_dining)"
40
+ def setup
41
+ configure_logging
42
+
43
+ puts "Heartland Sandbox Simulator - Entity Setup"
44
+ puts "=" * 50
45
+
46
+ generator = Generators::EntityGenerator.new(business_type: options[:business_type].to_sym)
47
+ generator.setup_all
48
+
49
+ puts "\nSetup complete!"
50
+ end
51
+
52
+ desc "generate", "Generate tickets for today"
53
+ option :count, type: :numeric, aliases: "-n", desc: "Number of tickets to generate"
54
+ option :refund_percentage, type: :numeric, aliases: "-r", default: 5,
55
+ desc: "Percentage of tickets to refund (0-100, default 5)"
56
+ def generate
57
+ configure_logging
58
+
59
+ puts "Heartland Sandbox Simulator - Ticket Generation"
60
+ puts "=" * 50
61
+
62
+ generator = Generators::OrderGenerator.new(refund_percentage: options[:refund_percentage])
63
+ count = options[:count]
64
+
65
+ tickets = generator.generate_today(count: count)
66
+
67
+ puts "\nGenerated #{tickets.size} tickets!"
68
+ end
69
+
70
+ desc "day", "Generate a realistic full day of operations"
71
+ option :multiplier, type: :numeric, aliases: "-x", default: 1.0, desc: "Ticket multiplier (0.5 = slow day, 2.0 = busy day)"
72
+ option :refund_percentage, type: :numeric, aliases: "-r", default: 5,
73
+ desc: "Percentage of tickets to refund (0-100, default 5)"
74
+ def day
75
+ configure_logging
76
+
77
+ puts "Heartland Sandbox Simulator - Realistic Day"
78
+ puts "=" * 50
79
+
80
+ generator = Generators::OrderGenerator.new(refund_percentage: options[:refund_percentage])
81
+ tickets = generator.generate_realistic_day(multiplier: options[:multiplier])
82
+
83
+ puts "\nGenerated #{tickets.size} tickets!"
84
+ end
85
+
86
+ desc "status", "Show current Heartland location status"
87
+ def status
88
+ configure_logging
89
+
90
+ puts "Heartland Sandbox Simulator - Status"
91
+ puts "=" * 50
92
+
93
+ services = Services::Heartland::ServiceManager.new
94
+
95
+ categories = services.catalog.list_categories
96
+ items = services.catalog.list_items
97
+
98
+ puts "\nEntity Counts:"
99
+ puts " Categories: #{categories.size}"
100
+ puts " Items: #{items.size}"
101
+
102
+ if categories.any?
103
+ puts "\nCategories:"
104
+ categories.each { |c| puts " - #{c["name"]}" }
105
+ end
106
+ end
107
+
108
+ desc "tickets", "List recent tickets"
109
+ option :limit, type: :numeric, aliases: "-n", default: 20, desc: "Number of tickets to show"
110
+ def tickets
111
+ configure_logging
112
+
113
+ puts "Heartland Sandbox Simulator - Recent Tickets"
114
+ puts "=" * 50
115
+
116
+ services = Services::Heartland::ServiceManager.new
117
+ recent = services.order.search_tickets
118
+
119
+ if recent.empty?
120
+ puts "No tickets found."
121
+ puts "\nRun 'simulate generate' to create tickets."
122
+ return
123
+ end
124
+
125
+ display = recent.first(options[:limit])
126
+
127
+ puts "\nFound #{recent.size} tickets (showing #{display.size}):\n\n"
128
+
129
+ total_revenue = 0
130
+ display.each do |ticket|
131
+ total = ticket["netTotal"] || ticket["total"] || 0
132
+ total_revenue += total
133
+ status_val = ticket["status"] || "unknown"
134
+ items_count = (ticket["items"] || []).size
135
+ guests = ticket["guestCount"] || 0
136
+
137
+ status_marker = case status_val
138
+ when "open" then "[OPEN]"
139
+ when "closed" then "[PAID]"
140
+ when "voided" then "[VOID]"
141
+ else "[#{status_val.upcase}]"
142
+ end
143
+
144
+ puts "#{status_marker.ljust(10)} $#{format("%.2f", total / 100.0)} | #{items_count} items | #{guests} guests | #{ticket["ticketId"]}"
145
+ end
146
+
147
+ puts "\n#{"-" * 50}"
148
+ puts "Total: $#{format("%.2f", total_revenue / 100.0)} across #{display.size} tickets"
149
+ end
150
+
151
+ desc "summary", "Show daily summary for current location"
152
+ option :date, type: :string, aliases: "-d", desc: "Date (YYYY-MM-DD, default: today)"
153
+ def summary
154
+ configure_logging
155
+ require_db_connection!
156
+
157
+ date = options[:date] ? Date.parse(options[:date]) : Date.today
158
+ location_id = HeartlandSandboxSimulator.configuration.location_id
159
+
160
+ s = Models::DailySummary.for_merchant(location_id).on_date(date).first
161
+
162
+ unless s
163
+ puts "No summary found for location #{location_id} on #{date}."
164
+ puts "Run 'simulate generate' to create tickets first."
165
+ return
166
+ end
167
+
168
+ puts "Daily Summary: #{date}"
169
+ puts "=" * 50
170
+ puts " Location: #{location_id}"
171
+ puts " Tickets: #{s.order_count}"
172
+ puts " Payments: #{s.payment_count}"
173
+ puts " Refunds: #{s.refund_count}"
174
+ puts ""
175
+ puts " Revenue: #{format_cents(s.total_revenue)}"
176
+ puts " Tax: #{format_cents(s.total_tax)}"
177
+ puts " Tips: #{format_cents(s.total_tips)}"
178
+ puts " Discounts: #{format_cents(s.total_discounts)}"
179
+
180
+ breakdown = s.breakdown || {}
181
+
182
+ if breakdown["guest_count"]
183
+ puts ""
184
+ puts " Guests: #{breakdown["guest_count"]}"
185
+ puts " Avg Ticket: #{format_cents(breakdown["average_ticket"] || 0)}"
186
+ puts " Avg/Guest: #{format_cents(breakdown["average_per_guest"] || 0)}"
187
+ end
188
+
189
+ if breakdown["by_meal_period"]&.any?
190
+ puts ""
191
+ puts " By Meal Period:"
192
+ breakdown["by_meal_period"].each do |period, count|
193
+ rev = breakdown.dig("revenue_by_meal_period", period) || 0
194
+ puts " #{period.ljust(15)} #{count.to_s.rjust(3)} tickets #{format_cents(rev)}"
195
+ end
196
+ end
197
+
198
+ if breakdown["by_revenue_center"]&.any?
199
+ puts ""
200
+ puts " By Revenue Center:"
201
+ breakdown["by_revenue_center"].each do |center, count|
202
+ rev = breakdown.dig("revenue_by_revenue_center", center) || 0
203
+ puts " #{center.ljust(20)} #{count.to_s.rjust(3)} tickets #{format_cents(rev)}"
204
+ end
205
+ end
206
+
207
+ return unless breakdown["by_tender"]&.any?
208
+
209
+ puts ""
210
+ puts " By Tender:"
211
+ breakdown["by_tender"].each do |tender, count|
212
+ amount = breakdown.dig("sales_by_tender", tender) || 0
213
+ puts " #{tender.ljust(20)} #{count} payments #{format_cents(amount)}"
214
+ end
215
+ end
216
+
217
+ desc "delete", "Delete all entities (use with caution!)"
218
+ option :confirm, type: :boolean, desc: "Confirm deletion"
219
+ def delete
220
+ unless options[:confirm]
221
+ puts "This will delete ALL entities from Heartland!"
222
+ puts " Run with --confirm to proceed."
223
+ return
224
+ end
225
+
226
+ configure_logging
227
+
228
+ puts "Deleting all entities..."
229
+
230
+ generator = Generators::EntityGenerator.new
231
+ generator.delete_all
232
+
233
+ puts "\nAll entities deleted!"
234
+ end
235
+
236
+ # ============================================
237
+ # Database Management Commands (db: namespace)
238
+ # ============================================
239
+
240
+ desc "db SUBCOMMAND", "Database management commands"
241
+ subcommand "db", Class.new(Thor) {
242
+ def self.banner(task, _namespace = true, _subcommand = true)
243
+ "simulate db #{task.usage}"
244
+ end
245
+
246
+ namespace "db"
247
+
248
+ desc "create", "Create the PostgreSQL database"
249
+ def create
250
+ db = HeartlandSandboxSimulator::Database
251
+ url = db.database_url
252
+ puts "Creating database..."
253
+ db.create!(url)
254
+ puts "Done."
255
+ rescue HeartlandSandboxSimulator::Error => e
256
+ puts "Error: #{e.message}"
257
+ exit 1
258
+ end
259
+
260
+ desc "migrate", "Run pending migrations"
261
+ def migrate
262
+ db = HeartlandSandboxSimulator::Database
263
+ url = db.database_url
264
+ puts "Connecting and running migrations..."
265
+ db.connect!(url)
266
+ db.migrate!
267
+ puts "Done."
268
+ rescue HeartlandSandboxSimulator::Error => e
269
+ puts "Error: #{e.message}"
270
+ exit 1
271
+ end
272
+
273
+ desc "seed", "Seed the database with realistic data via FactoryBot"
274
+ option :type, type: :string, desc: "Business type to seed (e.g. restaurant, cafe_bakery). Seeds all if omitted."
275
+ def seed
276
+ db = HeartlandSandboxSimulator::Database
277
+ url = db.database_url
278
+ puts "Connecting and seeding..."
279
+ db.connect!(url)
280
+ bt = options[:type]&.to_sym
281
+ result = db.seed!(business_type: bt)
282
+ puts "Seeded: #{result[:business_types]} business types, #{result[:categories]} categories, #{result[:items]} items"
283
+ puts " (#{result[:created]} created, #{result[:found]} already existed)"
284
+ rescue HeartlandSandboxSimulator::Error => e
285
+ puts "Error: #{e.message}"
286
+ exit 1
287
+ end
288
+
289
+ desc "reset", "Drop, create, migrate, and seed the database"
290
+ option :type, type: :string, desc: "Business type to seed (e.g. restaurant). Seeds all if omitted."
291
+ option :confirm, type: :boolean, desc: "Confirm destructive operation"
292
+ def reset
293
+ unless options[:confirm]
294
+ puts "This will DROP and recreate the database. All data will be lost."
295
+ puts "Run with --confirm to proceed."
296
+ return
297
+ end
298
+
299
+ db = HeartlandSandboxSimulator::Database
300
+ url = db.database_url
301
+
302
+ puts "Dropping database..."
303
+ db.drop!(url)
304
+
305
+ puts "Creating database..."
306
+ db.create!(url)
307
+
308
+ puts "Connecting and running migrations..."
309
+ db.connect!(url)
310
+ db.migrate!
311
+
312
+ puts "Seeding..."
313
+ bt = options[:type]&.to_sym
314
+ result = db.seed!(business_type: bt)
315
+ puts "Seeded: #{result[:business_types]} business types, #{result[:categories]} categories, #{result[:items]} items"
316
+ puts "Done."
317
+ rescue HeartlandSandboxSimulator::Error => e
318
+ puts "Error: #{e.message}"
319
+ exit 1
320
+ end
321
+ }
322
+
323
+ desc "version", "Show version"
324
+ def version
325
+ puts "Heartland Sandbox Simulator v#{HeartlandSandboxSimulator::VERSION}"
326
+ end
327
+
328
+ private
329
+
330
+ def format_cents(cents)
331
+ "$#{format("%.2f", (cents || 0) / 100.0)}"
332
+ end
333
+
334
+ def require_db_connection!
335
+ url = Database.database_url
336
+ Database.connect!(url) unless Database.connected?
337
+ rescue Error => e
338
+ puts "Database not available: #{e.message}"
339
+ puts "Run 'simulate db create && simulate db migrate' first."
340
+ exit 1
341
+ end
342
+
343
+ def configure_logging
344
+ config = HeartlandSandboxSimulator.configuration
345
+
346
+ config.logger.level = if options[:verbose]
347
+ Logger::DEBUG
348
+ else
349
+ Logger::INFO
350
+ end
351
+
352
+ # Load specific location if specified
353
+ if options[:location]
354
+ config.load_location(location_id: options[:location])
355
+ elsif options[:location_index]
356
+ config.load_location(index: options[:location_index])
357
+ end
358
+
359
+ puts "Using location: #{config.location_name || config.location_id}"
360
+ end
361
+ end
362
+ end
363
+
364
+ HeartlandSandboxSimulator::CLI.start(ARGV)
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeartlandSandboxSimulator
4
+ class Configuration
5
+ attr_accessor :location_id, :location_name, :access_token, :environment,
6
+ :log_level, :tax_rate, :business_type, :database_url, :location_timezone
7
+
8
+ # Default timezone if not fetched from Heartland
9
+ DEFAULT_TIMEZONE = "America/New_York"
10
+
11
+ # Path to locations JSON file
12
+ LOCATIONS_FILE = File.join(File.dirname(__FILE__), "..", "..", ".env.json")
13
+
14
+ def initialize
15
+ @location_id = ENV.fetch("HEARTLAND_LOCATION_ID", nil)
16
+ @location_name = ENV.fetch("HEARTLAND_LOCATION_NAME", nil)
17
+ @access_token = ENV.fetch("HEARTLAND_ACCESS_TOKEN", nil)
18
+ @environment = normalize_url(ENV.fetch("HEARTLAND_ENVIRONMENT", "https://api.hrpos.heartland.us/"))
19
+ @log_level = parse_log_level(ENV.fetch("LOG_LEVEL", "INFO"))
20
+ @tax_rate = ENV.fetch("TAX_RATE", "8.25").to_f
21
+ @business_type = ENV.fetch("BUSINESS_TYPE", "restaurant").to_sym
22
+ @database_url = ENV.fetch("DATABASE_URL", nil)
23
+
24
+ # Load from .env.json if location_id not set in ENV
25
+ load_from_locations_file if @location_id.nil? || @location_id.empty?
26
+ end
27
+
28
+ # Load configuration for a specific location from .env.json
29
+ #
30
+ # @param location_id [String, nil] Location ID to load (nil for first location)
31
+ # @param index [Integer, nil] Index of location in the list (0-based)
32
+ # @return [self]
33
+ def load_location(location_id: nil, index: nil)
34
+ locations = load_locations_file
35
+ return self if locations.empty?
36
+
37
+ location = if location_id
38
+ locations.find { |l| l["HEARTLAND_LOCATION_ID"] == location_id }
39
+ elsif index
40
+ locations[index]
41
+ else
42
+ locations.first
43
+ end
44
+
45
+ if location
46
+ apply_location_config(location)
47
+ logger.info "Loaded location: #{@location_name} (#{@location_id})"
48
+ else
49
+ logger.warn "Location not found: #{location_id || "index #{index}"}"
50
+ end
51
+
52
+ self
53
+ end
54
+
55
+ # List all available locations from .env.json
56
+ #
57
+ # @return [Array<Hash>] Array of location configs
58
+ def available_locations
59
+ load_locations_file.map do |l|
60
+ {
61
+ id: l["HEARTLAND_LOCATION_ID"],
62
+ name: l["HEARTLAND_LOCATION_NAME"],
63
+ }
64
+ end
65
+ end
66
+
67
+ def validate!
68
+ raise ConfigurationError, "HEARTLAND_LOCATION_ID is required" if location_id.nil? || location_id.empty?
69
+ raise ConfigurationError, "HEARTLAND_ACCESS_TOKEN is required" if access_token.nil? || access_token.empty?
70
+
71
+ true
72
+ end
73
+
74
+ def logger
75
+ @logger ||= Logger.new($stdout).tap do |log|
76
+ log.level = @log_level
77
+ log.formatter = proc do |severity, datetime, _progname, msg|
78
+ timestamp = datetime.strftime("%Y-%m-%d %H:%M:%S")
79
+ "[#{timestamp}] #{severity.ljust(5)} | #{msg}\n"
80
+ end
81
+ end
82
+ end
83
+
84
+ # Fetch location timezone from Heartland API
85
+ # @return [String] IANA timezone identifier (e.g., "America/New_York")
86
+ def fetch_location_timezone
87
+ return @location_timezone if @location_timezone
88
+
89
+ require "rest-client"
90
+ require "json"
91
+
92
+ url = "#{environment}v2/locations/#{location_id}"
93
+ response = RestClient.get(url, {
94
+ Authorization: "Bearer #{access_token}",
95
+ "Content-Type": "application/json",
96
+ })
97
+ data = JSON.parse(response.body)
98
+
99
+ @location_timezone = data.dig("location", "timezone") || DEFAULT_TIMEZONE
100
+ logger.info "Location timezone: #{@location_timezone}"
101
+ @location_timezone
102
+ rescue StandardError => e
103
+ logger.warn "Failed to fetch location timezone: #{e.message}. Using default: #{DEFAULT_TIMEZONE}"
104
+ @location_timezone = DEFAULT_TIMEZONE
105
+ end
106
+
107
+ # Get current time in location's timezone
108
+ # @return [Time] Current time in location timezone
109
+ def merchant_time_now
110
+ require "time"
111
+ tz = fetch_location_timezone
112
+ begin
113
+ require "tzinfo"
114
+ TZInfo::Timezone.get(tz).now
115
+ rescue LoadError
116
+ old_tz = ENV.fetch("TZ", nil)
117
+ ENV["TZ"] = tz
118
+ time = Time.now
119
+ ENV["TZ"] = old_tz
120
+ time
121
+ end
122
+ end
123
+
124
+ # Get today's date in location's timezone
125
+ # @return [Date] Today's date in location timezone
126
+ def merchant_date_today
127
+ merchant_time_now.to_date
128
+ end
129
+
130
+ private
131
+
132
+ def load_from_locations_file
133
+ locations = load_locations_file
134
+ return if locations.empty?
135
+
136
+ # Use first location by default
137
+ apply_location_config(locations.first)
138
+ end
139
+
140
+ # Parse .env.json, supporting both the legacy array format and the
141
+ # new object format: { "DATABASE_URL": "...", "locations": [...] }
142
+ def load_locations_file
143
+ return [] unless File.exist?(LOCATIONS_FILE)
144
+
145
+ data = JSON.parse(File.read(LOCATIONS_FILE))
146
+ return data if data.is_a?(Array) # legacy format
147
+
148
+ # New object format -- extract locations list
149
+ data.fetch("locations", [])
150
+ rescue JSON::ParserError => e
151
+ warn "Failed to parse #{LOCATIONS_FILE}: #{e.message}"
152
+ []
153
+ end
154
+
155
+ # Read the top-level DATABASE_URL from .env.json (new object format only).
156
+ # Returns nil when the file uses the legacy array format or has no key.
157
+ #
158
+ # @return [String, nil] The DATABASE_URL or nil
159
+ def self.database_url_from_file
160
+ return nil unless File.exist?(LOCATIONS_FILE)
161
+
162
+ data = JSON.parse(File.read(LOCATIONS_FILE))
163
+ return nil if data.is_a?(Array)
164
+
165
+ data["DATABASE_URL"]
166
+ rescue JSON::ParserError
167
+ nil
168
+ end
169
+
170
+ def apply_location_config(location)
171
+ @location_id = location["HEARTLAND_LOCATION_ID"]
172
+ @location_name = location["HEARTLAND_LOCATION_NAME"]
173
+ @access_token = location["HEARTLAND_ACCESS_TOKEN"] unless location["HEARTLAND_ACCESS_TOKEN"].to_s.empty?
174
+ end
175
+
176
+ def normalize_url(url)
177
+ url = url.strip
178
+ url.end_with?("/") ? url : "#{url}/"
179
+ end
180
+
181
+ def parse_log_level(level)
182
+ case level.to_s.upcase
183
+ when "DEBUG" then Logger::DEBUG
184
+ when "WARN" then Logger::WARN
185
+ when "ERROR" then Logger::ERROR
186
+ when "FATAL" then Logger::FATAL
187
+ else Logger::INFO # default for "INFO" and unrecognized levels
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,9 @@
1
+ {
2
+ "categories": [
3
+ { "name": "Draft Beer", "sort_order": 1, "description": "Beers on tap" },
4
+ { "name": "Cocktails", "sort_order": 2, "description": "Mixed drinks" },
5
+ { "name": "Spirits", "sort_order": 3, "description": "Neat, on the rocks, or shots" },
6
+ { "name": "Wine", "sort_order": 4, "description": "By the glass or bottle" },
7
+ { "name": "Bar Food", "sort_order": 5, "description": "Late-night bites" }
8
+ ]
9
+ }