gusto_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/LICENSE.txt +21 -0
- data/README.md +94 -0
- data/bin/simulate +309 -0
- data/lib/gusto_sandbox_simulator/configuration.rb +148 -0
- data/lib/gusto_sandbox_simulator/database.rb +137 -0
- data/lib/gusto_sandbox_simulator/db/factories/api_requests.rb +13 -0
- data/lib/gusto_sandbox_simulator/db/factories/business_types.rb +22 -0
- data/lib/gusto_sandbox_simulator/db/factories/employees.rb +144 -0
- data/lib/gusto_sandbox_simulator/db/factories/payroll_runs.rb +28 -0
- data/lib/gusto_sandbox_simulator/db/migrate/20260323000001_enable_pgcrypto.rb +7 -0
- data/lib/gusto_sandbox_simulator/db/migrate/20260323000002_create_business_types.rb +15 -0
- data/lib/gusto_sandbox_simulator/db/migrate/20260323000003_create_employees.rb +23 -0
- data/lib/gusto_sandbox_simulator/db/migrate/20260323000004_create_payroll_runs.rb +29 -0
- data/lib/gusto_sandbox_simulator/db/migrate/20260323000005_create_api_requests.rb +22 -0
- data/lib/gusto_sandbox_simulator/db/migrate/20260323000006_create_daily_summaries.rb +20 -0
- data/lib/gusto_sandbox_simulator/generators/entity_generator.rb +120 -0
- data/lib/gusto_sandbox_simulator/generators/payroll_generator.rb +222 -0
- data/lib/gusto_sandbox_simulator/models/api_request.rb +14 -0
- data/lib/gusto_sandbox_simulator/models/business_type.rb +13 -0
- data/lib/gusto_sandbox_simulator/models/daily_summary.rb +14 -0
- data/lib/gusto_sandbox_simulator/models/employee.rb +31 -0
- data/lib/gusto_sandbox_simulator/models/payroll_run.rb +35 -0
- data/lib/gusto_sandbox_simulator/models/record.rb +11 -0
- data/lib/gusto_sandbox_simulator/seeder.rb +90 -0
- data/lib/gusto_sandbox_simulator/services/base_service.rb +199 -0
- data/lib/gusto_sandbox_simulator/services/gusto/company_service.rb +27 -0
- data/lib/gusto_sandbox_simulator/services/gusto/employee_service.rb +57 -0
- data/lib/gusto_sandbox_simulator/services/gusto/payroll_service.rb +109 -0
- data/lib/gusto_sandbox_simulator/services/gusto/services_manager.rb +56 -0
- data/lib/gusto_sandbox_simulator/version.rb +5 -0
- data/lib/gusto_sandbox_simulator.rb +45 -0
- metadata +312 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 05fbbfad3d8f931e8543db90bf01c8a4967262fb87ab7322c286d6d6ff654461
|
|
4
|
+
data.tar.gz: 9bb1181850ca02294f88d02e4568bd4eca7abdd34e105a7d9fc9968e81af5936
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8aa21e1718afeafe1c6b5ba02d656164dd5abc7d25fcb62fc0bbb9660c2b3002d8b58cf5da19483dddade69e76c1337e4f64ec649d0d9205979c549e75a85c74
|
|
7
|
+
data.tar.gz: 433d08cd49f48026d42cad09eeb38e7afe7d76ee146c261cf04876a85219b5947a4884bffb4c42be7a0546cfa73b47b48619c5ccfb508b652917210fab9846a0
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Gusto Sandbox Simulator
|
|
2
|
+
|
|
3
|
+
Ruby gem that generates realistic payroll data (employees, payroll runs, compensations) in [Gusto](https://gusto.com) sandbox environments for testing and development.
|
|
4
|
+
|
|
5
|
+
Part of the [SalesToBooks](https://salestobooks.com) POS simulator ecosystem alongside [`clover_sandbox_simulator`](https://github.com/dan1d/clover_sandbox_simulator) and [`square_sandbox_simulator`](https://github.com/dan1d/square_sandbox_simulator).
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
gem install gusto_sandbox_simulator
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or add to your Gemfile:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
gem "gusto_sandbox_simulator"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Configuration
|
|
20
|
+
|
|
21
|
+
Create a `.env.json` file in the project root:
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"DATABASE_URL": "postgres://user:pass@localhost/gusto_sandbox",
|
|
26
|
+
"companies": [
|
|
27
|
+
{
|
|
28
|
+
"GUSTO_COMPANY_UUID": "abc-123",
|
|
29
|
+
"GUSTO_ACCESS_TOKEN": "your-token",
|
|
30
|
+
"GUSTO_COMPANY_NAME": "Demo Restaurant"
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Or set environment variables directly:
|
|
37
|
+
|
|
38
|
+
| Variable | Default | Required |
|
|
39
|
+
|----------|---------|----------|
|
|
40
|
+
| `GUSTO_COMPANY_UUID` | -- | Yes |
|
|
41
|
+
| `GUSTO_ACCESS_TOKEN` | -- | Yes |
|
|
42
|
+
| `GUSTO_ENVIRONMENT` | `demo` | No |
|
|
43
|
+
| `GUSTO_API_VERSION` | `2024-04-01` | No |
|
|
44
|
+
| `BUSINESS_TYPE` | `restaurant` | No |
|
|
45
|
+
| `DATABASE_URL` | -- | No |
|
|
46
|
+
|
|
47
|
+
## CLI Usage
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# Employee management
|
|
51
|
+
simulate setup # Create 14 restaurant employees in Gusto sandbox
|
|
52
|
+
simulate status # Show company info, employee/payroll counts
|
|
53
|
+
simulate delete --confirm # Delete local employee records
|
|
54
|
+
|
|
55
|
+
# Payroll processing
|
|
56
|
+
simulate process # Process unprocessed payrolls (fill hours, submit)
|
|
57
|
+
simulate sync -s 2026-01-01 # Sync processed payrolls to local DB
|
|
58
|
+
|
|
59
|
+
# Utilities
|
|
60
|
+
simulate companies # List companies from .env.json
|
|
61
|
+
simulate payrolls -p # List processed payrolls
|
|
62
|
+
simulate version # Show version
|
|
63
|
+
|
|
64
|
+
# Database management
|
|
65
|
+
simulate db create # Create PostgreSQL database
|
|
66
|
+
simulate db migrate # Run migrations
|
|
67
|
+
simulate db seed # Seed with restaurant employee data
|
|
68
|
+
simulate db reset --confirm # Drop, create, migrate, seed
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**Global flags:** `-v` (verbose), `-c UUID` (company), `-i INDEX` (company index)
|
|
72
|
+
|
|
73
|
+
## Default Restaurant Roster
|
|
74
|
+
|
|
75
|
+
14 employees across 4 departments:
|
|
76
|
+
|
|
77
|
+
| Department | Roles | Compensation |
|
|
78
|
+
|-----------|-------|-------------|
|
|
79
|
+
| Kitchen | Head Chef, Sous Chef, Line Cook x2, Prep Cook, Dishwasher | $14-55k |
|
|
80
|
+
| Management | General Manager | $50k salary |
|
|
81
|
+
| Front of House | Server x3, Host, Busser | $5.50-14/hr |
|
|
82
|
+
| Bar | Bartender x2 | $7/hr + tips |
|
|
83
|
+
|
|
84
|
+
## Development
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
bundle install
|
|
88
|
+
bundle exec rspec # Run tests
|
|
89
|
+
bundle exec rubocop # Lint
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## License
|
|
93
|
+
|
|
94
|
+
MIT License. See [LICENSE.txt](LICENSE.txt).
|
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 "gusto_sandbox_simulator"
|
|
6
|
+
require "thor"
|
|
7
|
+
|
|
8
|
+
module GustoSandboxSimulator
|
|
9
|
+
# Command-line interface for Gusto Sandbox Simulator
|
|
10
|
+
class CLI < Thor
|
|
11
|
+
class_option :verbose, type: :boolean, aliases: "-v", desc: "Enable verbose logging"
|
|
12
|
+
class_option :company, type: :string, aliases: "-c", desc: "Company UUID to use (from .env.json)"
|
|
13
|
+
class_option :company_index, type: :numeric, aliases: "-i", desc: "Company index to use (0-based)"
|
|
14
|
+
|
|
15
|
+
desc "companies", "List available companies from .env.json"
|
|
16
|
+
def companies
|
|
17
|
+
puts "Gusto Sandbox Simulator - Available Companies"
|
|
18
|
+
puts "=" * 60
|
|
19
|
+
|
|
20
|
+
config = GustoSandboxSimulator.configuration
|
|
21
|
+
comps = config.available_companies
|
|
22
|
+
|
|
23
|
+
if comps.empty?
|
|
24
|
+
puts "No companies found in .env.json"
|
|
25
|
+
return
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
puts "\n#{"Index".ljust(6)} #{"Company UUID".ljust(40)} Name"
|
|
29
|
+
puts "-" * 60
|
|
30
|
+
|
|
31
|
+
comps.each_with_index do |comp, idx|
|
|
32
|
+
puts "#{idx.to_s.ljust(6)} #{(comp[:uuid] || "N/A").ljust(40)} #{comp[:name] || "N/A"}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
puts "\nUse: simulate <command> -i <index> or -c <company_uuid>"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
desc "setup", "Set up employees in the Gusto sandbox company"
|
|
39
|
+
def setup
|
|
40
|
+
configure_logging
|
|
41
|
+
|
|
42
|
+
puts "Gusto Sandbox Simulator - Employee Setup"
|
|
43
|
+
puts "=" * 50
|
|
44
|
+
|
|
45
|
+
generator = Generators::EntityGenerator.new
|
|
46
|
+
result = generator.setup_all
|
|
47
|
+
|
|
48
|
+
puts "\nSetup complete!"
|
|
49
|
+
puts " Created: #{result[:created]}"
|
|
50
|
+
puts " Found: #{result[:found]}"
|
|
51
|
+
puts " Total: #{result[:total]}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
desc "process", "Process unprocessed payrolls (fill compensations and submit)"
|
|
55
|
+
def process
|
|
56
|
+
configure_logging
|
|
57
|
+
|
|
58
|
+
puts "Gusto Sandbox Simulator - Process Payrolls"
|
|
59
|
+
puts "=" * 50
|
|
60
|
+
|
|
61
|
+
generator = Generators::PayrollGenerator.new
|
|
62
|
+
result = generator.process_unprocessed
|
|
63
|
+
|
|
64
|
+
puts "\nPayroll processing complete!"
|
|
65
|
+
puts " Processed: #{result[:processed]}"
|
|
66
|
+
puts " Skipped: #{result[:skipped]}"
|
|
67
|
+
puts " Errors: #{result[:errors].size}"
|
|
68
|
+
|
|
69
|
+
result[:errors].each { |e| puts " - #{e}" } if result[:errors].any?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
desc "sync", "Sync processed payrolls from Gusto to local database"
|
|
73
|
+
option :start_date, type: :string, aliases: "-s", desc: "Start date (YYYY-MM-DD)"
|
|
74
|
+
option :end_date, type: :string, aliases: "-e", desc: "End date (YYYY-MM-DD)"
|
|
75
|
+
def sync
|
|
76
|
+
configure_logging
|
|
77
|
+
require_db_connection!
|
|
78
|
+
|
|
79
|
+
puts "Gusto Sandbox Simulator - Sync Payrolls"
|
|
80
|
+
puts "=" * 50
|
|
81
|
+
|
|
82
|
+
generator = Generators::PayrollGenerator.new
|
|
83
|
+
start_date = options[:start_date] ? Date.parse(options[:start_date]) : nil
|
|
84
|
+
end_date = options[:end_date] ? Date.parse(options[:end_date]) : nil
|
|
85
|
+
|
|
86
|
+
payrolls = generator.sync_processed(start_date: start_date, end_date: end_date)
|
|
87
|
+
|
|
88
|
+
puts "\nSynced #{payrolls.size} processed payrolls"
|
|
89
|
+
|
|
90
|
+
payrolls.each do |p|
|
|
91
|
+
period = p["pay_period"] || {}
|
|
92
|
+
totals = p["totals"] || {}
|
|
93
|
+
puts " #{period["start_date"]} - #{period["end_date"]}: " \
|
|
94
|
+
"Gross $#{totals["gross_pay"]} | Net $#{totals["net_pay"]} | " \
|
|
95
|
+
"#{(p["employee_compensations"] || []).size} employees"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
desc "status", "Show current Gusto company status"
|
|
100
|
+
def status
|
|
101
|
+
configure_logging
|
|
102
|
+
|
|
103
|
+
puts "Gusto Sandbox Simulator - Status"
|
|
104
|
+
puts "=" * 50
|
|
105
|
+
|
|
106
|
+
services = Services::Gusto::ServicesManager.new
|
|
107
|
+
|
|
108
|
+
company = services.company.get_company
|
|
109
|
+
employees = services.employee.list_employees
|
|
110
|
+
payrolls = services.payroll.list_payrolls
|
|
111
|
+
|
|
112
|
+
puts "\nCompany: #{company["name"] || company["trade_name"] || "N/A"}"
|
|
113
|
+
puts " UUID: #{config.company_uuid}"
|
|
114
|
+
puts " EIN: #{company["ein"] || "N/A"}"
|
|
115
|
+
puts ""
|
|
116
|
+
puts "Entity Counts:"
|
|
117
|
+
puts " Employees: #{employees.size}"
|
|
118
|
+
puts " Payroll Runs: #{payrolls.size}"
|
|
119
|
+
|
|
120
|
+
processed = payrolls.count { |p| p["processed"] }
|
|
121
|
+
unprocessed = payrolls.size - processed
|
|
122
|
+
puts " Processed: #{processed}"
|
|
123
|
+
puts " Unprocessed: #{unprocessed}"
|
|
124
|
+
|
|
125
|
+
return unless employees.any?
|
|
126
|
+
|
|
127
|
+
puts "\nEmployees:"
|
|
128
|
+
employees.each do |emp|
|
|
129
|
+
puts " - #{emp["first_name"]} #{emp["last_name"]}"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
desc "payrolls", "List recent payrolls"
|
|
134
|
+
option :limit, type: :numeric, aliases: "-n", default: 10, desc: "Number to show"
|
|
135
|
+
option :processed, type: :boolean, aliases: "-p", desc: "Only show processed"
|
|
136
|
+
def payrolls
|
|
137
|
+
configure_logging
|
|
138
|
+
|
|
139
|
+
puts "Gusto Sandbox Simulator - Payrolls"
|
|
140
|
+
puts "=" * 50
|
|
141
|
+
|
|
142
|
+
services = Services::Gusto::ServicesManager.new
|
|
143
|
+
runs = if options[:processed]
|
|
144
|
+
services.payroll.processed_payrolls
|
|
145
|
+
else
|
|
146
|
+
services.payroll.list_payrolls
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
if runs.empty?
|
|
150
|
+
puts "No payrolls found."
|
|
151
|
+
return
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
display = runs.first(options[:limit])
|
|
155
|
+
puts "\nFound #{runs.size} payrolls (showing #{display.size}):\n\n"
|
|
156
|
+
|
|
157
|
+
display.each do |p|
|
|
158
|
+
period = p["pay_period"] || {}
|
|
159
|
+
totals = p["totals"] || {}
|
|
160
|
+
status_tag = p["processed"] ? "[PROCESSED]" : "[PENDING] "
|
|
161
|
+
emps = (p["employee_compensations"] || []).size
|
|
162
|
+
puts "#{status_tag} #{period["start_date"]} - #{period["end_date"]} | " \
|
|
163
|
+
"Gross: $#{totals["gross_pay"] || "0.00"} | #{emps} employees"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
desc "delete", "Delete all local employee records"
|
|
168
|
+
option :confirm, type: :boolean, desc: "Confirm deletion"
|
|
169
|
+
def delete
|
|
170
|
+
unless options[:confirm]
|
|
171
|
+
puts "This will delete all LOCAL employee records."
|
|
172
|
+
puts "Run with --confirm to proceed."
|
|
173
|
+
return
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
configure_logging
|
|
177
|
+
require_db_connection!
|
|
178
|
+
|
|
179
|
+
generator = Generators::EntityGenerator.new
|
|
180
|
+
generator.delete_all
|
|
181
|
+
|
|
182
|
+
puts "All local records deleted."
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Database management subcommand
|
|
186
|
+
desc "db SUBCOMMAND", "Database management commands"
|
|
187
|
+
subcommand "db", Class.new(Thor) {
|
|
188
|
+
def self.banner(task, _namespace = true, _subcommand = true)
|
|
189
|
+
"simulate db #{task.usage}"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
namespace "db"
|
|
193
|
+
|
|
194
|
+
desc "create", "Create the PostgreSQL database"
|
|
195
|
+
def create
|
|
196
|
+
db = GustoSandboxSimulator::Database
|
|
197
|
+
url = db.database_url
|
|
198
|
+
puts "Creating database..."
|
|
199
|
+
db.create!(url)
|
|
200
|
+
puts "Done."
|
|
201
|
+
rescue GustoSandboxSimulator::Error => e
|
|
202
|
+
puts "Error: #{e.message}"
|
|
203
|
+
exit 1
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
desc "migrate", "Run pending migrations"
|
|
207
|
+
def migrate
|
|
208
|
+
db = GustoSandboxSimulator::Database
|
|
209
|
+
url = db.database_url
|
|
210
|
+
puts "Connecting and running migrations..."
|
|
211
|
+
db.connect!(url)
|
|
212
|
+
db.migrate!
|
|
213
|
+
puts "Done."
|
|
214
|
+
rescue GustoSandboxSimulator::Error => e
|
|
215
|
+
puts "Error: #{e.message}"
|
|
216
|
+
exit 1
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
desc "seed", "Seed the database with restaurant employee data"
|
|
220
|
+
option :type, type: :string, desc: "Business type (default: restaurant)"
|
|
221
|
+
def seed
|
|
222
|
+
db = GustoSandboxSimulator::Database
|
|
223
|
+
url = db.database_url
|
|
224
|
+
puts "Connecting and seeding..."
|
|
225
|
+
db.connect!(url)
|
|
226
|
+
bt = options[:type]&.to_sym
|
|
227
|
+
result = db.seed!(business_type: bt)
|
|
228
|
+
puts "Seeded: #{result[:business_types]} business types, #{result[:employees]} employees"
|
|
229
|
+
puts " (#{result[:created]} created, #{result[:found]} already existed)"
|
|
230
|
+
rescue GustoSandboxSimulator::Error => e
|
|
231
|
+
puts "Error: #{e.message}"
|
|
232
|
+
exit 1
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
desc "reset", "Drop, create, migrate, and seed the database"
|
|
236
|
+
option :type, type: :string, desc: "Business type (default: restaurant)"
|
|
237
|
+
option :confirm, type: :boolean, desc: "Confirm destructive operation"
|
|
238
|
+
def reset
|
|
239
|
+
unless options[:confirm]
|
|
240
|
+
puts "This will DROP and recreate the database. All data will be lost."
|
|
241
|
+
puts "Run with --confirm to proceed."
|
|
242
|
+
return
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
db = GustoSandboxSimulator::Database
|
|
246
|
+
url = db.database_url
|
|
247
|
+
|
|
248
|
+
puts "Dropping database..."
|
|
249
|
+
db.drop!(url)
|
|
250
|
+
|
|
251
|
+
puts "Creating database..."
|
|
252
|
+
db.create!(url)
|
|
253
|
+
|
|
254
|
+
puts "Connecting and running migrations..."
|
|
255
|
+
db.connect!(url)
|
|
256
|
+
db.migrate!
|
|
257
|
+
|
|
258
|
+
puts "Seeding..."
|
|
259
|
+
bt = options[:type]&.to_sym
|
|
260
|
+
result = db.seed!(business_type: bt)
|
|
261
|
+
puts "Seeded: #{result[:business_types]} business types, #{result[:employees]} employees"
|
|
262
|
+
puts "Done."
|
|
263
|
+
rescue GustoSandboxSimulator::Error => e
|
|
264
|
+
puts "Error: #{e.message}"
|
|
265
|
+
exit 1
|
|
266
|
+
end
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
desc "version", "Show version"
|
|
270
|
+
def version
|
|
271
|
+
puts "Gusto Sandbox Simulator v#{GustoSandboxSimulator::VERSION}"
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
private
|
|
275
|
+
|
|
276
|
+
def config
|
|
277
|
+
GustoSandboxSimulator.configuration
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def require_db_connection!
|
|
281
|
+
url = Database.database_url
|
|
282
|
+
Database.connect!(url) unless Database.connected?
|
|
283
|
+
rescue Error => e
|
|
284
|
+
puts "Database not available: #{e.message}"
|
|
285
|
+
puts "Run 'simulate db create && simulate db migrate' first."
|
|
286
|
+
exit 1
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def configure_logging
|
|
290
|
+
cfg = GustoSandboxSimulator.configuration
|
|
291
|
+
|
|
292
|
+
cfg.logger.level = if options[:verbose]
|
|
293
|
+
Logger::DEBUG
|
|
294
|
+
else
|
|
295
|
+
Logger::INFO
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
if options[:company]
|
|
299
|
+
cfg.load_company(company_uuid: options[:company])
|
|
300
|
+
elsif options[:company_index]
|
|
301
|
+
cfg.load_company(index: options[:company_index])
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
puts "Using company: #{cfg.company_uuid}"
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
GustoSandboxSimulator::CLI.start(ARGV)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GustoSandboxSimulator
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :company_uuid, :access_token, :environment, :api_version,
|
|
6
|
+
:log_level, :database_url, :business_type
|
|
7
|
+
|
|
8
|
+
# Gusto API base URLs
|
|
9
|
+
DEMO_URL = 'https://api.gusto-demo.com/'
|
|
10
|
+
PRODUCTION_URL = 'https://api.gusto.com/'
|
|
11
|
+
|
|
12
|
+
# Default API version header
|
|
13
|
+
DEFAULT_API_VERSION = '2024-04-01'
|
|
14
|
+
|
|
15
|
+
# Path to companies JSON file
|
|
16
|
+
COMPANIES_FILE = File.join(File.dirname(__FILE__), '..', '..', '.env.json')
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@company_uuid = ENV.fetch('GUSTO_COMPANY_UUID', nil)
|
|
20
|
+
@access_token = ENV.fetch('GUSTO_ACCESS_TOKEN', nil)
|
|
21
|
+
@environment = resolve_environment(ENV.fetch('GUSTO_ENVIRONMENT', 'demo'))
|
|
22
|
+
@api_version = ENV.fetch('GUSTO_API_VERSION', DEFAULT_API_VERSION)
|
|
23
|
+
@log_level = parse_log_level(ENV.fetch('LOG_LEVEL', 'INFO'))
|
|
24
|
+
@business_type = ENV.fetch('BUSINESS_TYPE', 'restaurant').to_sym
|
|
25
|
+
@database_url = ENV.fetch('DATABASE_URL', nil)
|
|
26
|
+
|
|
27
|
+
# Load from .env.json if company_uuid not set in ENV
|
|
28
|
+
load_from_companies_file if @company_uuid.nil? || @company_uuid.empty?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Load configuration for a specific company from .env.json
|
|
32
|
+
#
|
|
33
|
+
# @param company_uuid [String, nil] Company UUID to load (nil for first company)
|
|
34
|
+
# @param index [Integer, nil] Index of company in the list (0-based)
|
|
35
|
+
# @return [self]
|
|
36
|
+
def load_company(company_uuid: nil, index: nil)
|
|
37
|
+
companies = load_companies_file
|
|
38
|
+
return self if companies.empty?
|
|
39
|
+
|
|
40
|
+
company = if company_uuid
|
|
41
|
+
companies.find { |c| c['GUSTO_COMPANY_UUID'] == company_uuid }
|
|
42
|
+
elsif index
|
|
43
|
+
companies[index]
|
|
44
|
+
else
|
|
45
|
+
companies.first
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
if company
|
|
49
|
+
apply_company_config(company)
|
|
50
|
+
logger.info "Loaded company: #{@company_uuid}"
|
|
51
|
+
else
|
|
52
|
+
logger.warn "Company not found: #{company_uuid || "index #{index}"}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
self
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# List all available companies from .env.json
|
|
59
|
+
#
|
|
60
|
+
# @return [Array<Hash>] Array of company configs
|
|
61
|
+
def available_companies
|
|
62
|
+
load_companies_file.map do |c|
|
|
63
|
+
{
|
|
64
|
+
uuid: c['GUSTO_COMPANY_UUID'],
|
|
65
|
+
name: c['GUSTO_COMPANY_NAME']
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def validate!
|
|
71
|
+
raise ConfigurationError, 'GUSTO_COMPANY_UUID is required' if company_uuid.nil? || company_uuid.empty?
|
|
72
|
+
raise ConfigurationError, 'GUSTO_ACCESS_TOKEN is required' if access_token.nil? || access_token.empty?
|
|
73
|
+
|
|
74
|
+
true
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def logger
|
|
78
|
+
@logger ||= Logger.new($stdout).tap do |log|
|
|
79
|
+
log.level = @log_level
|
|
80
|
+
log.formatter = proc do |severity, datetime, _progname, msg|
|
|
81
|
+
timestamp = datetime.strftime('%Y-%m-%d %H:%M:%S')
|
|
82
|
+
"[#{timestamp}] #{severity.ljust(5)} | #{msg}\n"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Return the configured database URL from .env.json.
|
|
88
|
+
#
|
|
89
|
+
# @return [String, nil] The DATABASE_URL or nil
|
|
90
|
+
def self.database_url_from_file
|
|
91
|
+
return nil unless File.exist?(COMPANIES_FILE)
|
|
92
|
+
|
|
93
|
+
data = JSON.parse(File.read(COMPANIES_FILE))
|
|
94
|
+
return nil if data.is_a?(Array)
|
|
95
|
+
|
|
96
|
+
data['DATABASE_URL']
|
|
97
|
+
rescue JSON::ParserError
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def load_from_companies_file
|
|
104
|
+
companies = load_companies_file
|
|
105
|
+
return if companies.empty?
|
|
106
|
+
|
|
107
|
+
apply_company_config(companies.first)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Parse .env.json, supporting both the legacy array format and the
|
|
111
|
+
# new object format: { "DATABASE_URL": "...", "companies": [...] }
|
|
112
|
+
def load_companies_file
|
|
113
|
+
return [] unless File.exist?(COMPANIES_FILE)
|
|
114
|
+
|
|
115
|
+
data = JSON.parse(File.read(COMPANIES_FILE))
|
|
116
|
+
return data if data.is_a?(Array)
|
|
117
|
+
|
|
118
|
+
data.fetch('companies', [])
|
|
119
|
+
rescue JSON::ParserError => e
|
|
120
|
+
warn "Failed to parse #{COMPANIES_FILE}: #{e.message}"
|
|
121
|
+
[]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def apply_company_config(company)
|
|
125
|
+
@company_uuid = company['GUSTO_COMPANY_UUID']
|
|
126
|
+
@access_token = company['GUSTO_ACCESS_TOKEN'] unless company['GUSTO_ACCESS_TOKEN'].to_s.empty?
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def resolve_environment(env)
|
|
130
|
+
case env.to_s.downcase
|
|
131
|
+
when 'production', 'prod'
|
|
132
|
+
PRODUCTION_URL
|
|
133
|
+
else
|
|
134
|
+
DEMO_URL
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def parse_log_level(level)
|
|
139
|
+
case level.to_s.upcase
|
|
140
|
+
when 'DEBUG' then Logger::DEBUG
|
|
141
|
+
when 'WARN' then Logger::WARN
|
|
142
|
+
when 'ERROR' then Logger::ERROR
|
|
143
|
+
when 'FATAL' then Logger::FATAL
|
|
144
|
+
else Logger::INFO
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|