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.
- checksums.yaml +7 -0
- data/README.md +105 -0
- data/bin/simulate +364 -0
- data/lib/heartland_sandbox_simulator/configuration.rb +191 -0
- data/lib/heartland_sandbox_simulator/data/bar_nightclub/categories.json +9 -0
- data/lib/heartland_sandbox_simulator/data/bar_nightclub/items.json +29 -0
- data/lib/heartland_sandbox_simulator/data/bar_nightclub/tenders.json +11 -0
- data/lib/heartland_sandbox_simulator/data/cafe_bakery/categories.json +9 -0
- data/lib/heartland_sandbox_simulator/data/cafe_bakery/items.json +29 -0
- data/lib/heartland_sandbox_simulator/data/cafe_bakery/tenders.json +11 -0
- data/lib/heartland_sandbox_simulator/data/fine_dining/categories.json +9 -0
- data/lib/heartland_sandbox_simulator/data/fine_dining/items.json +29 -0
- data/lib/heartland_sandbox_simulator/data/fine_dining/tenders.json +11 -0
- data/lib/heartland_sandbox_simulator/data/pizzeria/categories.json +9 -0
- data/lib/heartland_sandbox_simulator/data/pizzeria/items.json +29 -0
- data/lib/heartland_sandbox_simulator/data/pizzeria/tenders.json +11 -0
- data/lib/heartland_sandbox_simulator/data/restaurant/categories.json +29 -0
- data/lib/heartland_sandbox_simulator/data/restaurant/items.json +29 -0
- data/lib/heartland_sandbox_simulator/data/restaurant/tenders.json +13 -0
- data/lib/heartland_sandbox_simulator/database.rb +182 -0
- data/lib/heartland_sandbox_simulator/db/factories/business_types.rb +100 -0
- data/lib/heartland_sandbox_simulator/db/factories/categories.rb +219 -0
- data/lib/heartland_sandbox_simulator/db/factories/items.rb +961 -0
- data/lib/heartland_sandbox_simulator/db/factories/simulated_orders.rb +100 -0
- data/lib/heartland_sandbox_simulator/db/factories/simulated_payments.rb +78 -0
- data/lib/heartland_sandbox_simulator/db/migrate/20260316000000_enable_pgcrypto.rb +7 -0
- data/lib/heartland_sandbox_simulator/db/migrate/20260316000001_create_business_types.rb +18 -0
- data/lib/heartland_sandbox_simulator/db/migrate/20260316000002_create_categories.rb +18 -0
- data/lib/heartland_sandbox_simulator/db/migrate/20260316000003_create_items.rb +23 -0
- data/lib/heartland_sandbox_simulator/db/migrate/20260316000004_create_simulated_orders.rb +38 -0
- data/lib/heartland_sandbox_simulator/db/migrate/20260316000005_create_simulated_payments.rb +26 -0
- data/lib/heartland_sandbox_simulator/db/migrate/20260316000006_create_api_requests.rb +27 -0
- data/lib/heartland_sandbox_simulator/db/migrate/20260316000007_create_daily_summaries.rb +24 -0
- data/lib/heartland_sandbox_simulator/generators/data_loader.rb +113 -0
- data/lib/heartland_sandbox_simulator/generators/entity_generator.rb +117 -0
- data/lib/heartland_sandbox_simulator/generators/order_generator.rb +609 -0
- data/lib/heartland_sandbox_simulator/models/api_request.rb +43 -0
- data/lib/heartland_sandbox_simulator/models/business_type.rb +23 -0
- data/lib/heartland_sandbox_simulator/models/category.rb +18 -0
- data/lib/heartland_sandbox_simulator/models/daily_summary.rb +82 -0
- data/lib/heartland_sandbox_simulator/models/item.rb +33 -0
- data/lib/heartland_sandbox_simulator/models/record.rb +14 -0
- data/lib/heartland_sandbox_simulator/models/simulated_order.rb +40 -0
- data/lib/heartland_sandbox_simulator/models/simulated_payment.rb +28 -0
- data/lib/heartland_sandbox_simulator/seeder.rb +152 -0
- data/lib/heartland_sandbox_simulator/services/base_service.rb +205 -0
- data/lib/heartland_sandbox_simulator/services/heartland/catalog_service.rb +122 -0
- data/lib/heartland_sandbox_simulator/services/heartland/location_service.rb +34 -0
- data/lib/heartland_sandbox_simulator/services/heartland/order_service.rb +122 -0
- data/lib/heartland_sandbox_simulator/services/heartland/payment_service.rb +88 -0
- data/lib/heartland_sandbox_simulator/services/heartland/service_manager.rb +64 -0
- data/lib/heartland_sandbox_simulator/version.rb +5 -0
- data/lib/heartland_sandbox_simulator.rb +47 -0
- 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
|
+
[](https://www.ruby-lang.org)
|
|
4
|
+
[](https://rspec.info)
|
|
5
|
+
[](coverage/index.html)
|
|
6
|
+
[](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
|
+
}
|