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.
Files changed (33) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +94 -0
  4. data/bin/simulate +309 -0
  5. data/lib/gusto_sandbox_simulator/configuration.rb +148 -0
  6. data/lib/gusto_sandbox_simulator/database.rb +137 -0
  7. data/lib/gusto_sandbox_simulator/db/factories/api_requests.rb +13 -0
  8. data/lib/gusto_sandbox_simulator/db/factories/business_types.rb +22 -0
  9. data/lib/gusto_sandbox_simulator/db/factories/employees.rb +144 -0
  10. data/lib/gusto_sandbox_simulator/db/factories/payroll_runs.rb +28 -0
  11. data/lib/gusto_sandbox_simulator/db/migrate/20260323000001_enable_pgcrypto.rb +7 -0
  12. data/lib/gusto_sandbox_simulator/db/migrate/20260323000002_create_business_types.rb +15 -0
  13. data/lib/gusto_sandbox_simulator/db/migrate/20260323000003_create_employees.rb +23 -0
  14. data/lib/gusto_sandbox_simulator/db/migrate/20260323000004_create_payroll_runs.rb +29 -0
  15. data/lib/gusto_sandbox_simulator/db/migrate/20260323000005_create_api_requests.rb +22 -0
  16. data/lib/gusto_sandbox_simulator/db/migrate/20260323000006_create_daily_summaries.rb +20 -0
  17. data/lib/gusto_sandbox_simulator/generators/entity_generator.rb +120 -0
  18. data/lib/gusto_sandbox_simulator/generators/payroll_generator.rb +222 -0
  19. data/lib/gusto_sandbox_simulator/models/api_request.rb +14 -0
  20. data/lib/gusto_sandbox_simulator/models/business_type.rb +13 -0
  21. data/lib/gusto_sandbox_simulator/models/daily_summary.rb +14 -0
  22. data/lib/gusto_sandbox_simulator/models/employee.rb +31 -0
  23. data/lib/gusto_sandbox_simulator/models/payroll_run.rb +35 -0
  24. data/lib/gusto_sandbox_simulator/models/record.rb +11 -0
  25. data/lib/gusto_sandbox_simulator/seeder.rb +90 -0
  26. data/lib/gusto_sandbox_simulator/services/base_service.rb +199 -0
  27. data/lib/gusto_sandbox_simulator/services/gusto/company_service.rb +27 -0
  28. data/lib/gusto_sandbox_simulator/services/gusto/employee_service.rb +57 -0
  29. data/lib/gusto_sandbox_simulator/services/gusto/payroll_service.rb +109 -0
  30. data/lib/gusto_sandbox_simulator/services/gusto/services_manager.rb +56 -0
  31. data/lib/gusto_sandbox_simulator/version.rb +5 -0
  32. data/lib/gusto_sandbox_simulator.rb +45 -0
  33. 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