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
|
@@ -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,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
|