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
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record'
4
+ require 'logger'
5
+
6
+ module GustoSandboxSimulator
7
+ # Standalone ActiveRecord connection manager for PostgreSQL.
8
+ #
9
+ # Provides database connectivity without requiring Rails.
10
+ # Used for persisting Gusto sandbox data (employees, payroll runs, etc.)
11
+ module Database
12
+ MIGRATIONS_PATH = File.expand_path('db/migrate', __dir__).freeze
13
+ TEST_DATABASE = 'gusto_simulator_test'
14
+
15
+ class << self
16
+ def create!(url)
17
+ db_name = URI.parse(url).path.delete_prefix('/')
18
+ maintenance_url = url.sub(%r{/[^/]+\z}, '/postgres')
19
+
20
+ ActiveRecord::Base.establish_connection(maintenance_url)
21
+ ActiveRecord::Base.connection.create_database(db_name)
22
+ GustoSandboxSimulator.logger.info("Database created: #{db_name}")
23
+ rescue ActiveRecord::DatabaseAlreadyExists, ActiveRecord::StatementInvalid => e
24
+ raise unless e.message.include?('already exists')
25
+
26
+ GustoSandboxSimulator.logger.info("Database already exists: #{db_name}")
27
+ ensure
28
+ ActiveRecord::Base.connection_pool.disconnect!
29
+ end
30
+
31
+ def drop!(url)
32
+ db_name = URI.parse(url).path.delete_prefix('/')
33
+ maintenance_url = url.sub(%r{/[^/]+\z}, '/postgres')
34
+
35
+ ActiveRecord::Base.establish_connection(maintenance_url)
36
+ ActiveRecord::Base.connection.drop_database(db_name)
37
+ GustoSandboxSimulator.logger.info("Database dropped: #{db_name}")
38
+ rescue ActiveRecord::StatementInvalid => e
39
+ raise unless e.message.include?('does not exist')
40
+
41
+ GustoSandboxSimulator.logger.info("Database does not exist: #{db_name}")
42
+ ensure
43
+ ActiveRecord::Base.connection_pool.disconnect!
44
+ end
45
+
46
+ def database_url
47
+ url = Configuration.database_url_from_file
48
+ raise Error, 'No DATABASE_URL found in .env.json' unless url
49
+
50
+ url
51
+ end
52
+
53
+ def connect!(url)
54
+ unless url.match?(%r{\Apostgres(ql)?://}i)
55
+ raise ArgumentError,
56
+ "Expected a PostgreSQL URL (postgres:// or postgresql://), got: #{url.split('://').first}://"
57
+ end
58
+
59
+ ActiveRecord::Base.establish_connection(url)
60
+ ActiveRecord::Base.connection.execute('SELECT 1')
61
+ ActiveRecord::Base.logger = GustoSandboxSimulator.logger
62
+
63
+ GustoSandboxSimulator.logger.info("Database connected: #{sanitize_url(url)}")
64
+ end
65
+
66
+ def migrate!
67
+ ensure_connected!
68
+
69
+ GustoSandboxSimulator.logger.info("Running migrations from #{MIGRATIONS_PATH}")
70
+ context = ActiveRecord::MigrationContext.new(MIGRATIONS_PATH)
71
+ context.migrate
72
+ GustoSandboxSimulator.logger.info('Migrations complete')
73
+ end
74
+
75
+ def seed!(business_type: nil)
76
+ ensure_connected!
77
+ load_factories!
78
+
79
+ business_type ||= GustoSandboxSimulator.configuration.business_type
80
+ Seeder.seed!(business_type: business_type)
81
+ end
82
+
83
+ def connected?
84
+ ActiveRecord::Base.connection_pool.with_connection(&:active?)
85
+ rescue StandardError
86
+ false
87
+ end
88
+
89
+ def disconnect!
90
+ ActiveRecord::Base.connection_pool.disconnect!
91
+ GustoSandboxSimulator.logger.info('Database disconnected')
92
+ end
93
+
94
+ def test_database_url(base_url: nil)
95
+ url = base_url || Configuration.database_url_from_file
96
+ return "postgres://localhost:5432/#{TEST_DATABASE}" if url.nil?
97
+
98
+ uri = URI.parse(url)
99
+ uri.path = "/#{TEST_DATABASE}"
100
+ uri.to_s
101
+ rescue URI::InvalidURIError
102
+ "postgres://localhost:5432/#{TEST_DATABASE}"
103
+ end
104
+
105
+ private
106
+
107
+ def ensure_connected!
108
+ return if connected?
109
+
110
+ raise GustoSandboxSimulator::Error,
111
+ 'Database not connected. Call Database.connect!(url) first.'
112
+ end
113
+
114
+ def load_factories!
115
+ return if @factories_loaded
116
+
117
+ require 'factory_bot'
118
+
119
+ factories_path = File.expand_path('db/factories', __dir__)
120
+ FactoryBot.definition_file_paths = [factories_path] if Dir.exist?(factories_path)
121
+ FactoryBot.find_definitions
122
+ @factories_loaded = true
123
+ rescue StandardError => e
124
+ GustoSandboxSimulator.logger.warn("Could not load factories: #{e.message}")
125
+ end
126
+
127
+ def sanitize_url(url)
128
+ uri = URI.parse(url)
129
+ uri.password = '***' if uri.password
130
+ uri.user = '***' if uri.user
131
+ uri.to_s
132
+ rescue URI::InvalidURIError
133
+ url.gsub(%r{://[^@]+@}, '://***:***@')
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ FactoryBot.define do
4
+ factory :api_request, class: 'GustoSandboxSimulator::Models::ApiRequest' do
5
+ http_method { 'GET' }
6
+ url { 'https://api.gusto-demo.com/v1/companies/test-uuid/payrolls' }
7
+ request_payload { {} }
8
+ response_payload { {} }
9
+ response_status { 200 }
10
+ duration_ms { 150 }
11
+ gusto_company_uuid { 'test-company-uuid' }
12
+ end
13
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ FactoryBot.define do
4
+ factory :business_type, class: 'GustoSandboxSimulator::Models::BusinessType' do
5
+ key { 'restaurant' }
6
+ name { 'Restaurant' }
7
+ industry { 'food' }
8
+ payroll_profile do
9
+ {
10
+ 'pay_frequency' => 'bi_weekly',
11
+ 'typical_employee_count' => 14,
12
+ 'departments' => ['Kitchen', 'Front of House', 'Bar', 'Management']
13
+ }
14
+ end
15
+
16
+ trait :restaurant do
17
+ key { 'restaurant' }
18
+ name { 'Restaurant' }
19
+ industry { 'food' }
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ FactoryBot.define do
4
+ factory :employee, class: 'GustoSandboxSimulator::Models::Employee' do
5
+ association :business_type
6
+ first_name { 'John' }
7
+ last_name { 'Doe' }
8
+ role { 'Line Cook' }
9
+ department { 'Kitchen' }
10
+ compensation_type { 'hourly' }
11
+ rate_cents { 1_700 }
12
+ active { true }
13
+
14
+ # Kitchen staff
15
+ trait :head_chef do
16
+ first_name { 'Maria' }
17
+ last_name { 'Rodriguez' }
18
+ role { 'Head Chef' }
19
+ department { 'Kitchen' }
20
+ compensation_type { 'salary' }
21
+ rate_cents { 5_500_000 }
22
+ end
23
+
24
+ trait :sous_chef do
25
+ first_name { 'James' }
26
+ last_name { 'Chen' }
27
+ role { 'Sous Chef' }
28
+ department { 'Kitchen' }
29
+ compensation_type { 'salary' }
30
+ rate_cents { 4_500_000 }
31
+ end
32
+
33
+ trait :line_cook_1 do
34
+ first_name { 'David' }
35
+ last_name { 'Kim' }
36
+ role { 'Line Cook' }
37
+ department { 'Kitchen' }
38
+ compensation_type { 'hourly' }
39
+ rate_cents { 1_800 }
40
+ end
41
+
42
+ trait :line_cook_2 do
43
+ first_name { 'Sarah' }
44
+ last_name { 'Johnson' }
45
+ role { 'Line Cook' }
46
+ department { 'Kitchen' }
47
+ compensation_type { 'hourly' }
48
+ rate_cents { 1_700 }
49
+ end
50
+
51
+ trait :prep_cook do
52
+ first_name { 'Miguel' }
53
+ last_name { 'Torres' }
54
+ role { 'Prep Cook' }
55
+ department { 'Kitchen' }
56
+ compensation_type { 'hourly' }
57
+ rate_cents { 1_500 }
58
+ end
59
+
60
+ trait :dishwasher do
61
+ first_name { 'Omar' }
62
+ last_name { 'Hassan' }
63
+ role { 'Dishwasher' }
64
+ department { 'Kitchen' }
65
+ compensation_type { 'hourly' }
66
+ rate_cents { 1_400 }
67
+ end
68
+
69
+ # Management
70
+ trait :general_manager do
71
+ first_name { 'Jessica' }
72
+ last_name { 'Williams' }
73
+ role { 'General Manager' }
74
+ department { 'Management' }
75
+ compensation_type { 'salary' }
76
+ rate_cents { 5_000_000 }
77
+ end
78
+
79
+ # Front of House
80
+ trait :server_1 do
81
+ first_name { 'Emily' }
82
+ last_name { 'Davis' }
83
+ role { 'Server' }
84
+ department { 'Front of House' }
85
+ compensation_type { 'hourly' }
86
+ rate_cents { 550 }
87
+ end
88
+
89
+ trait :server_2 do
90
+ first_name { 'Brandon' }
91
+ last_name { 'Lee' }
92
+ role { 'Server' }
93
+ department { 'Front of House' }
94
+ compensation_type { 'hourly' }
95
+ rate_cents { 550 }
96
+ end
97
+
98
+ trait :server_3 do
99
+ first_name { 'Ashley' }
100
+ last_name { 'Martinez' }
101
+ role { 'Server' }
102
+ department { 'Front of House' }
103
+ compensation_type { 'hourly' }
104
+ rate_cents { 550 }
105
+ end
106
+
107
+ trait :host do
108
+ first_name { 'Sophia' }
109
+ last_name { 'Anderson' }
110
+ role { 'Host' }
111
+ department { 'Front of House' }
112
+ compensation_type { 'hourly' }
113
+ rate_cents { 1_400 }
114
+ end
115
+
116
+ trait :busser do
117
+ first_name { 'Marcus' }
118
+ last_name { 'Wilson' }
119
+ role { 'Busser' }
120
+ department { 'Front of House' }
121
+ compensation_type { 'hourly' }
122
+ rate_cents { 1_200 }
123
+ end
124
+
125
+ # Bar
126
+ trait :bartender_1 do
127
+ first_name { 'Tyler' }
128
+ last_name { 'Brown' }
129
+ role { 'Bartender' }
130
+ department { 'Bar' }
131
+ compensation_type { 'hourly' }
132
+ rate_cents { 700 }
133
+ end
134
+
135
+ trait :bartender_2 do
136
+ first_name { 'Nicole' }
137
+ last_name { 'Taylor' }
138
+ role { 'Bartender' }
139
+ department { 'Bar' }
140
+ compensation_type { 'hourly' }
141
+ rate_cents { 700 }
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ FactoryBot.define do
4
+ factory :payroll_run, class: 'GustoSandboxSimulator::Models::PayrollRun' do
5
+ gusto_company_uuid { SecureRandom.uuid }
6
+ gusto_payroll_id { SecureRandom.uuid }
7
+ pay_period_start { Date.new(2026, 3, 1) }
8
+ pay_period_end { Date.new(2026, 3, 15) }
9
+ check_date { Date.new(2026, 3, 20) }
10
+ status { 'processed' }
11
+ gross_pay_cents { 1_500_000 }
12
+ net_pay_cents { 1_125_000 }
13
+ employer_taxes_cents { 120_000 }
14
+ employee_taxes_cents { 225_000 }
15
+ benefits_cents { 80_000 }
16
+ reimbursements_cents { 15_000 }
17
+ employee_count { 14 }
18
+ employee_compensations { [] }
19
+
20
+ trait :processed do
21
+ status { 'processed' }
22
+ end
23
+
24
+ trait :pending do
25
+ status { 'pending' }
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class EnablePgcrypto < ActiveRecord::Migration[8.0]
4
+ def change
5
+ enable_extension 'pgcrypto' unless extension_enabled?('pgcrypto')
6
+ end
7
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateBusinessTypes < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :business_types, id: :uuid, default: 'gen_random_uuid()' do |t|
6
+ t.string :key, null: false
7
+ t.string :name, null: false
8
+ t.string :industry, null: false
9
+ t.jsonb :payroll_profile, default: {}
10
+ t.timestamps
11
+ end
12
+
13
+ add_index :business_types, :key, unique: true
14
+ end
15
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateEmployees < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :employees, id: :uuid, default: 'gen_random_uuid()' do |t|
6
+ t.references :business_type, type: :uuid, foreign_key: true, null: false
7
+ t.string :first_name, null: false
8
+ t.string :last_name, null: false
9
+ t.string :email
10
+ t.string :role, null: false
11
+ t.string :department, null: false
12
+ t.string :compensation_type, null: false, default: 'hourly'
13
+ t.integer :rate_cents, null: false
14
+ t.boolean :active, null: false, default: true
15
+ t.string :gusto_employee_uuid
16
+ t.jsonb :metadata, default: {}
17
+ t.timestamps
18
+ end
19
+
20
+ add_index :employees, :gusto_employee_uuid, unique: true, where: 'gusto_employee_uuid IS NOT NULL'
21
+ add_index :employees, :department
22
+ end
23
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreatePayrollRuns < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :payroll_runs, id: :uuid, default: 'gen_random_uuid()' do |t|
6
+ t.string :gusto_company_uuid, null: false
7
+ t.string :gusto_payroll_id
8
+ t.date :pay_period_start, null: false
9
+ t.date :pay_period_end, null: false
10
+ t.date :check_date, null: false
11
+ t.string :status, null: false, default: 'processed'
12
+ t.integer :gross_pay_cents, null: false, default: 0
13
+ t.integer :net_pay_cents, null: false, default: 0
14
+ t.integer :employer_taxes_cents, null: false, default: 0
15
+ t.integer :employee_taxes_cents, null: false, default: 0
16
+ t.integer :benefits_cents, null: false, default: 0
17
+ t.integer :reimbursements_cents, null: false, default: 0
18
+ t.integer :employee_count, null: false, default: 0
19
+ t.jsonb :employee_compensations, default: []
20
+ t.jsonb :metadata, default: {}
21
+ t.timestamps
22
+ end
23
+
24
+ add_index :payroll_runs, :gusto_payroll_id, unique: true, where: 'gusto_payroll_id IS NOT NULL'
25
+ add_index :payroll_runs, :gusto_company_uuid
26
+ add_index :payroll_runs, %i[gusto_company_uuid pay_period_start pay_period_end],
27
+ unique: true, name: 'idx_payroll_runs_company_period'
28
+ end
29
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateApiRequests < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :api_requests, id: :uuid, default: 'gen_random_uuid()' do |t|
6
+ t.string :http_method, null: false
7
+ t.string :url, null: false
8
+ t.jsonb :request_payload, default: {}
9
+ t.jsonb :response_payload, default: {}
10
+ t.integer :response_status
11
+ t.integer :duration_ms
12
+ t.string :error_message
13
+ t.string :resource_type
14
+ t.string :resource_id
15
+ t.string :gusto_company_uuid
16
+ t.timestamps
17
+ end
18
+
19
+ add_index :api_requests, :created_at
20
+ add_index :api_requests, :gusto_company_uuid
21
+ end
22
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateDailySummaries < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :daily_summaries, id: :uuid, default: 'gen_random_uuid()' do |t|
6
+ t.string :gusto_company_uuid, null: false
7
+ t.date :business_date, null: false
8
+ t.integer :payroll_run_count, default: 0
9
+ t.integer :employee_count, default: 0
10
+ t.integer :total_gross_pay_cents, default: 0
11
+ t.integer :total_net_pay_cents, default: 0
12
+ t.integer :total_taxes_cents, default: 0
13
+ t.integer :total_benefits_cents, default: 0
14
+ t.jsonb :breakdown, default: {}
15
+ t.timestamps
16
+ end
17
+
18
+ add_index :daily_summaries, %i[gusto_company_uuid business_date], unique: true
19
+ end
20
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GustoSandboxSimulator
4
+ module Generators
5
+ # Creates employees in the Gusto sandbox company.
6
+ # Idempotent — matches existing employees by name before creating.
7
+ class EntityGenerator
8
+ attr_reader :services, :config, :logger
9
+
10
+ # Default restaurant employee roster
11
+ RESTAURANT_EMPLOYEES = [
12
+ { first_name: 'Maria', last_name: 'Rodriguez', role: 'Head Chef', department: 'Kitchen',
13
+ compensation_type: 'salary', rate_cents: 5_500_000 },
14
+ { first_name: 'James', last_name: 'Chen', role: 'Sous Chef', department: 'Kitchen',
15
+ compensation_type: 'salary', rate_cents: 4_500_000 },
16
+ { first_name: 'David', last_name: 'Kim', role: 'Line Cook', department: 'Kitchen',
17
+ compensation_type: 'hourly', rate_cents: 1_800 },
18
+ { first_name: 'Sarah', last_name: 'Johnson', role: 'Line Cook', department: 'Kitchen',
19
+ compensation_type: 'hourly', rate_cents: 1_700 },
20
+ { first_name: 'Miguel', last_name: 'Torres', role: 'Prep Cook', department: 'Kitchen',
21
+ compensation_type: 'hourly', rate_cents: 1_500 },
22
+ { first_name: 'Omar', last_name: 'Hassan', role: 'Dishwasher', department: 'Kitchen',
23
+ compensation_type: 'hourly', rate_cents: 1_400 },
24
+ { first_name: 'Jessica', last_name: 'Williams', role: 'General Manager', department: 'Management',
25
+ compensation_type: 'salary', rate_cents: 5_000_000 },
26
+ { first_name: 'Emily', last_name: 'Davis', role: 'Server', department: 'Front of House',
27
+ compensation_type: 'hourly', rate_cents: 550 },
28
+ { first_name: 'Brandon', last_name: 'Lee', role: 'Server', department: 'Front of House',
29
+ compensation_type: 'hourly', rate_cents: 550 },
30
+ { first_name: 'Ashley', last_name: 'Martinez', role: 'Server', department: 'Front of House',
31
+ compensation_type: 'hourly', rate_cents: 550 },
32
+ { first_name: 'Tyler', last_name: 'Brown', role: 'Bartender', department: 'Bar',
33
+ compensation_type: 'hourly', rate_cents: 700 },
34
+ { first_name: 'Nicole', last_name: 'Taylor', role: 'Bartender', department: 'Bar',
35
+ compensation_type: 'hourly', rate_cents: 700 },
36
+ { first_name: 'Sophia', last_name: 'Anderson', role: 'Host', department: 'Front of House',
37
+ compensation_type: 'hourly', rate_cents: 1_400 },
38
+ { first_name: 'Marcus', last_name: 'Wilson', role: 'Busser', department: 'Front of House',
39
+ compensation_type: 'hourly', rate_cents: 1_200 }
40
+ ].freeze
41
+
42
+ def initialize(config: nil)
43
+ @config = config || GustoSandboxSimulator.configuration
44
+ @services = Services::Gusto::ServicesManager.new(config: @config)
45
+ @logger = @config.logger
46
+ end
47
+
48
+ # Create all employees in Gusto sandbox. Idempotent.
49
+ #
50
+ # @return [Hash] Summary { created: N, found: N, total: N }
51
+ def setup_all
52
+ logger.info "Setting up employees for company #{config.company_uuid}..."
53
+
54
+ existing = services.employee.list_employees
55
+ existing_names = existing.to_set { |e| "#{e['first_name']} #{e['last_name']}".downcase }
56
+
57
+ created = 0
58
+ found = 0
59
+
60
+ RESTAURANT_EMPLOYEES.each do |emp|
61
+ full_name = "#{emp[:first_name]} #{emp[:last_name]}".downcase
62
+
63
+ if existing_names.include?(full_name)
64
+ logger.debug "Employee already exists: #{emp[:first_name]} #{emp[:last_name]}"
65
+ found += 1
66
+ else
67
+ create_employee(emp)
68
+ created += 1
69
+ end
70
+
71
+ # Persist to local DB if connected
72
+ persist_employee(emp) if Database.connected?
73
+ end
74
+
75
+ summary = { created: created, found: found, total: RESTAURANT_EMPLOYEES.size }
76
+ logger.info "Employee setup complete: #{summary}"
77
+ summary
78
+ end
79
+
80
+ # Delete all locally tracked employees (does not remove from Gusto)
81
+ def delete_all
82
+ return unless Database.connected?
83
+
84
+ Models::Employee.delete_all
85
+ logger.info 'All local employee records deleted'
86
+ end
87
+
88
+ private
89
+
90
+ def create_employee(emp)
91
+ logger.info "Creating employee: #{emp[:first_name]} #{emp[:last_name]} (#{emp[:role]})"
92
+
93
+ services.employee.create_employee(
94
+ first_name: emp[:first_name],
95
+ last_name: emp[:last_name]
96
+ )
97
+ rescue ApiError => e
98
+ logger.warn "Failed to create #{emp[:first_name]} #{emp[:last_name]}: #{e.message}"
99
+ end
100
+
101
+ def persist_employee(emp)
102
+ bt = Models::BusinessType.find_by(key: config.business_type.to_s)
103
+ return unless bt
104
+
105
+ Models::Employee.find_or_create_by!(
106
+ first_name: emp[:first_name],
107
+ last_name: emp[:last_name],
108
+ business_type: bt
109
+ ) do |record|
110
+ record.role = emp[:role]
111
+ record.department = emp[:department]
112
+ record.compensation_type = emp[:compensation_type]
113
+ record.rate_cents = emp[:rate_cents]
114
+ end
115
+ rescue StandardError => e
116
+ logger.debug "Failed to persist employee locally: #{e.message}"
117
+ end
118
+ end
119
+ end
120
+ end