Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion e2e/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { PaymentFlow } from '@/components/Contractor/Payments/PaymentFlow/Paymen
import { TerminationFlow } from '@/components/Employee/Terminations/TerminationFlow/TerminationFlow'
import { DismissalFlow } from '@/components/Payroll/Dismissal'
import { TimeOffFlow } from '@/components/TimeOff/TimeOffFlow/TimeOffFlow'
import { StateTaxesForm } from '@/components/Company/StateTaxes/StateTaxesForm/StateTaxesForm'
import '@/styles/sdk.scss'

const DEFAULT_API_BASE_URL = 'https://api.gusto.com'
Expand All @@ -26,6 +27,7 @@ type FlowType =
| 'termination'
| 'dismissal'
| 'time-off'
| 'state-taxes-form'

interface E2EConfig {
flow: FlowType
Expand All @@ -36,6 +38,7 @@ interface E2EConfig {
startDate: string
endDate: string
payScheduleUuid: string
state: string
}

function getConfigFromUrl(): E2EConfig {
Expand All @@ -57,11 +60,12 @@ function getConfigFromUrl(): E2EConfig {
startDate: params.get('startDate') || '2025-08-14',
endDate: params.get('endDate') || '2025-08-27',
payScheduleUuid: params.get('payScheduleUuid') || '1478a82e-b45c-4980-843a-6ddc3b78268e',
state: params.get('state') || 'WA',
}
}

function FlowRenderer({ config }: { config: E2EConfig }) {
const { flow, companyId, employeeId, startDate, endDate, payScheduleUuid } = config
const { flow, companyId, employeeId, startDate, endDate, payScheduleUuid, state } = config
const handleEvent = () => {}

switch (flow) {
Expand Down Expand Up @@ -95,6 +99,8 @@ function FlowRenderer({ config }: { config: E2EConfig }) {
return <DismissalFlow companyId={companyId} employeeId={employeeId} onEvent={handleEvent} />
case 'time-off':
return <TimeOffFlow companyId={companyId} onEvent={handleEvent} />
case 'state-taxes-form':
return <StateTaxesForm companyId={companyId} state={state} onEvent={handleEvent} />
default:
return <div>Unknown flow: {flow}</div>
}
Expand All @@ -111,6 +117,7 @@ const FLOW_OPTIONS: { value: FlowType; label: string }[] = [
{ value: 'termination', label: 'Termination' },
{ value: 'dismissal', label: 'Dismissal' },
{ value: 'time-off', label: 'Time Off Management' },
{ value: 'state-taxes-form', label: 'State Taxes Form' },
]

function FlowSelector({ currentFlow }: { currentFlow: FlowType }) {
Expand Down
165 changes: 165 additions & 0 deletions e2e/scenario/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
ContractorDecoration,
PayScheduleDecoration,
PayrollDecoration,
StateTaxDecoration,
} from '../scenarios/schema/scenario.types'

const DEFAULT_GWS_FLOWS_HOST = 'https://flows.gusto-demo.com'
Expand Down Expand Up @@ -91,6 +92,162 @@ async function decorateLocations(
return locationIds
}

interface CurrentTaxRequirement {
key: string
value: string | number | boolean | null
editable?: boolean
applicable_if?: Array<{ key: string; value: string | number | boolean | null }>
}

interface CurrentTaxRequirementSet {
key: string
effective_from?: string | null
requirements?: CurrentTaxRequirement[]
}

interface CurrentTaxRequirementsResponse {
requirement_sets?: CurrentTaxRequirementSet[]
}

async function decorateStateTaxes(
api: ApiClient,
companyId: string,
stateTaxes: StateTaxDecoration[],
log: ReturnType<typeof makeLog>,
): Promise<void> {
for (const entry of stateTaxes) {
log(`Pre-seeding tax requirements for ${entry.state}`)
// Whether a given requirement is currently applicable depends on
// other requirement values that may not be set yet on a freshly-
// decorated demo. Naïvely PUTting answers for keys that the API
// considers non-applicable returns 422; instead, GET the current
// shape first and only PUT the subset of declared answers that:
// (a) appear in the current requirement_sets, AND
// (b) have their applicable_if satisfied by current values.
// Anything else is logged and skipped so the scenario provisions
// and the test surfaces whatever real shape the demo backend gave
// us. The decorator is best-effort — it nudges the demo toward the
// declared state without trying to forge invariants the backend
// doesn't expose.
let current: CurrentTaxRequirementsResponse
try {
current = await api.get<CurrentTaxRequirementsResponse>(
`/companies/${companyId}/tax_requirements/${entry.state}`,
)
} catch (error) {
log(
` Could not GET tax_requirements for ${entry.state} (${String(error)}); skipping state-tax pre-seed for this state`,
)
continue
}

const currentSets = current.requirement_sets ?? []
const currentBySetKey = new Map<string, CurrentTaxRequirementSet>(
currentSets.map(set => [set.key, set]),
)

log(
` Discovered ${currentSets.length} requirement set(s) for ${entry.state}: ${
currentSets.map(s => `${s.key} (${s.requirements?.length ?? 0} req)`).join(', ') || '(none)'
}`,
)
for (const set of currentSets) {
for (const req of set.requirements ?? []) {
const gates =
req.applicable_if && req.applicable_if.length > 0
? ` ⟵ ${req.applicable_if.map(c => `${c.key}=${String(c.value)}`).join(' && ')}`
: ''
log(
` ${set.key}.${req.key} = ${JSON.stringify(req.value)}${gates}${req.editable === false ? ' [non-editable]' : ''}`,
)
}
}

const requirementSetsToPut: Array<{
state: string
key: string
effective_from?: string | null
requirements: Array<{ key: string; value: string | number | boolean }>
}> = []

for (const declaredSet of entry.requirementSets) {
const currentSet = currentBySetKey.get(declaredSet.key)
if (!currentSet) {
log(
` Skipping pre-seed for ${entry.state}.${declaredSet.key}: set not present on demo backend`,
)
continue
}

const currentByKey = new Map<string, CurrentTaxRequirement>(
(currentSet.requirements ?? []).map(r => [r.key, r]),
)

const acceptedRequirements: Array<{ key: string; value: string | number | boolean }> = []

for (const declaredReq of declaredSet.requirements) {
const currentReq = currentByKey.get(declaredReq.key)
if (!currentReq) {
log(
` Skipping ${entry.state}.${declaredSet.key}.${declaredReq.key}: key not present on demo backend`,
)
continue
}
if (currentReq.editable === false) {
log(
` Skipping ${entry.state}.${declaredSet.key}.${declaredReq.key}: not editable on demo backend`,
)
continue
}
// applicable_if mirrors the SDK's helper: AND-logic, strict equality
// against other requirement values in the same set.
const constraints = currentReq.applicable_if ?? []
const allConstraintsMet = constraints.every(c => currentByKey.get(c.key)?.value === c.value)
if (!allConstraintsMet) {
log(
` Skipping ${entry.state}.${declaredSet.key}.${declaredReq.key}: applicable_if not satisfied by current values`,
)
continue
}
if (currentReq.value === declaredReq.value) {
log(
` ${entry.state}.${declaredSet.key}.${declaredReq.key} already at desired value (${String(declaredReq.value)}); no-op`,
)
continue
}
acceptedRequirements.push({ key: declaredReq.key, value: declaredReq.value })
}

if (acceptedRequirements.length === 0) continue

requirementSetsToPut.push({
state: entry.state,
key: declaredSet.key,
...(declaredSet.effective_from !== undefined
? { effective_from: declaredSet.effective_from }
: currentSet.effective_from !== undefined
? { effective_from: currentSet.effective_from }
: {}),
requirements: acceptedRequirements,
})
}

if (requirementSetsToPut.length === 0) {
log(` Nothing to PUT for ${entry.state} — declared state already matches or unsupported`)
continue
}

log(
` PUTting ${requirementSetsToPut.length} requirement set(s) for ${entry.state}: ${requirementSetsToPut
.map(s => `${s.key}(${s.requirements.length})`)
.join(', ')}`,
)
await api.put(`/companies/${companyId}/tax_requirements/${entry.state}`, {
requirement_sets: requirementSetsToPut,
})
}
}

async function decorateEmployees(
api: ApiClient,
companyId: string,
Expand Down Expand Up @@ -723,6 +880,14 @@ export async function provisionScenario(
? await decorateEmployees(api, companyId, decorations.employees, locationIds, log)
: {}

// stateTaxes runs AFTER employees because Gusto only surfaces state-tax
// requirement_sets once the company has nexus in the state — typically
// an employee with a home address there. Decorating before employees
// means GET returns empty for every new state and there's nothing to seed.
if (decorations.stateTaxes) {
await decorateStateTaxes(api, companyId, decorations.stateTaxes, log)
}

const contractorIds = decorations.contractors
? await decorateContractors(api, companyId, decorations.contractors, log)
: {}
Expand Down
58 changes: 57 additions & 1 deletion e2e/scenarios/schema/scenario.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,16 @@
"decorations": {
"type": "object",
"additionalProperties": false,
"description": "Entities to create/update on top of the base demo. Order is fixed: locations -> employees (+ addresses, jobs, compensations, onboarding_status) -> contractors -> paySchedule -> payrolls.",
"description": "Entities to create/update on top of the base demo. Order is fixed: locations -> employees (+ addresses, jobs, compensations, onboarding_status) -> stateTaxes -> contractors -> paySchedule -> payrolls. stateTaxes runs after employees because Gusto only exposes state-tax requirement_sets once the company has nexus (an employee with a home address) in the state.",
"properties": {
"locations": {
"type": "array",
"items": { "$ref": "#/definitions/locationDecoration" }
},
"stateTaxes": {
"type": "array",
"items": { "$ref": "#/definitions/stateTaxDecoration" }
},
"employees": {
"type": "array",
"items": { "$ref": "#/definitions/employeeDecoration" }
Expand Down Expand Up @@ -321,6 +325,58 @@
"anchor_end_of_pay_period": { "type": "string" }
}
},
"stateTaxDecoration": {
"type": "object",
"required": ["state", "requirementSets"],
"additionalProperties": false,
"description": "Pre-seed tax_requirements answers for a specific state. Runner PUTs the body to /companies/:id/tax_requirements/:state after locations are decorated. The state must already be relevant to the company (i.e., the company has an employee or location in that state, OR the base demo seeds the state — the demo backend returns a 404 otherwise).",
"properties": {
"state": {
"type": "string",
"minLength": 2,
"maxLength": 2,
"description": "Two-letter state code (e.g., WA, ID)."
},
"requirementSets": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["key", "requirements"],
"additionalProperties": false,
"properties": {
"key": {
"type": "string",
"description": "Requirement set key returned by GET /tax_requirements/:state (e.g., 'taxrates', 'registrations')."
},
"effective_from": {
"type": ["string", "null"],
"description": "Optional effective-from date in YYYY-MM-DD; omit to use whatever the API surfaces."
},
"requirements": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["key", "value"],
"additionalProperties": false,
"properties": {
"key": {
"type": "string",
"description": "Requirement key (e.g., 'usedefaultsuirates', 'suireimbursable', or a UUID)."
},
"value": {
"description": "Required value: string, number, or boolean — must match the metadata.type returned by the API (radio=boolean, text/account_number=string, tax_rate/percent/currency=number-as-string).",
"type": ["string", "number", "boolean"]
}
}
}
}
}
}
}
}
},
"payrollDecoration": {
"type": "object",
"required": ["key", "type"],
Expand Down
Loading
Loading