diff --git a/e2e/main.tsx b/e2e/main.tsx
index c63ab0887..968ae9644 100644
--- a/e2e/main.tsx
+++ b/e2e/main.tsx
@@ -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'
@@ -26,6 +27,7 @@ type FlowType =
| 'termination'
| 'dismissal'
| 'time-off'
+ | 'state-taxes-form'
interface E2EConfig {
flow: FlowType
@@ -36,6 +38,7 @@ interface E2EConfig {
startDate: string
endDate: string
payScheduleUuid: string
+ state: string
}
function getConfigFromUrl(): E2EConfig {
@@ -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) {
@@ -95,6 +99,8 @@ function FlowRenderer({ config }: { config: E2EConfig }) {
return
case 'time-off':
return
+ case 'state-taxes-form':
+ return
default:
return
Unknown flow: {flow}
}
@@ -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 }) {
diff --git a/e2e/scenario/runner.ts b/e2e/scenario/runner.ts
index 087b26411..cc22af62d 100644
--- a/e2e/scenario/runner.ts
+++ b/e2e/scenario/runner.ts
@@ -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'
@@ -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,
+): Promise {
+ 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(
+ `/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(
+ 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(
+ (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,
@@ -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)
: {}
diff --git a/e2e/scenarios/schema/scenario.schema.json b/e2e/scenarios/schema/scenario.schema.json
index 6c0289c67..caf4c23af 100644
--- a/e2e/scenarios/schema/scenario.schema.json
+++ b/e2e/scenarios/schema/scenario.schema.json
@@ -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" }
@@ -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"],
diff --git a/e2e/scenarios/schema/scenario.types.ts b/e2e/scenarios/schema/scenario.types.ts
index ed96fc846..66d9c40c9 100644
--- a/e2e/scenarios/schema/scenario.types.ts
+++ b/e2e/scenarios/schema/scenario.types.ts
@@ -119,7 +119,7 @@ export interface Scenario {
*/
description?: string
/**
- * Domain the scenario belongs to. Used by the fixture to auto-tag tests with @.
+ * Domain the scenario belongs to. Used by the fixture to auto-tag tests with @. Use 'shared' for scenarios that are consumed by tests across multiple domain folders (e.g., shared/onboarded-ro is used by payroll, employee, contractor, and time-off RO tests).
*/
domain:
| 'payroll'
@@ -139,10 +139,11 @@ export interface Scenario {
| 'react_sdk_demo_employee_self_onboarding'
| 'react_sdk_demo_contractor_onboarding'
/**
- * Entities to create/update on top of the base demo. Order is fixed: locations -> employees (+ addresses, jobs, compensations, onboarding_status) -> contractors -> paySchedule -> payrolls.
+ * 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.
*/
decorations: {
locations?: LocationDecoration[]
+ stateTaxes?: StateTaxDecoration[]
employees?: EmployeeDecoration[]
contractors?: ContractorDecoration[]
paySchedule?: PayScheduleDecoration
@@ -186,6 +187,90 @@ export interface FragmentRef {
}
[k: string]: unknown
}
+/**
+ * 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).
+ */
+export interface StateTaxDecoration {
+ /**
+ * Two-letter state code (e.g., WA, ID).
+ */
+ state: string
+ /**
+ * @minItems 1
+ */
+ requirementSets: [
+ {
+ /**
+ * Requirement set key returned by GET /tax_requirements/:state (e.g., 'taxrates', 'registrations').
+ */
+ key: string
+ /**
+ * Optional effective-from date in YYYY-MM-DD; omit to use whatever the API surfaces.
+ */
+ effective_from?: string | null
+ /**
+ * @minItems 1
+ */
+ requirements: [
+ {
+ /**
+ * Requirement key (e.g., 'usedefaultsuirates', 'suireimbursable', or a UUID).
+ */
+ key: string
+ /**
+ * 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).
+ */
+ value: string | number | boolean
+ },
+ ...{
+ /**
+ * Requirement key (e.g., 'usedefaultsuirates', 'suireimbursable', or a UUID).
+ */
+ key: string
+ /**
+ * 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).
+ */
+ value: string | number | boolean
+ }[],
+ ]
+ },
+ ...{
+ /**
+ * Requirement set key returned by GET /tax_requirements/:state (e.g., 'taxrates', 'registrations').
+ */
+ key: string
+ /**
+ * Optional effective-from date in YYYY-MM-DD; omit to use whatever the API surfaces.
+ */
+ effective_from?: string | null
+ /**
+ * @minItems 1
+ */
+ requirements: [
+ {
+ /**
+ * Requirement key (e.g., 'usedefaultsuirates', 'suireimbursable', or a UUID).
+ */
+ key: string
+ /**
+ * 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).
+ */
+ value: string | number | boolean
+ },
+ ...{
+ /**
+ * Requirement key (e.g., 'usedefaultsuirates', 'suireimbursable', or a UUID).
+ */
+ key: string
+ /**
+ * 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).
+ */
+ value: string | number | boolean
+ }[],
+ ]
+ }[],
+ ]
+}
export interface PayScheduleDecoration {
frequency:
| 'Every week'
diff --git a/e2e/scenarios/shared/state-taxes-wa-id.json b/e2e/scenarios/shared/state-taxes-wa-id.json
new file mode 100644
index 000000000..8fdf1256b
--- /dev/null
+++ b/e2e/scenarios/shared/state-taxes-wa-id.json
@@ -0,0 +1,90 @@
+{
+ "$schema": "../schema/scenario.schema.json",
+ "name": "Company registered in WA and ID via employees with home addresses in each state",
+ "description": "Fresh react_sdk_demo decorated with WA and ID locations and two employees whose home addresses sit in those states. Gusto's tax-nexus activation surfaces state-tax requirement_sets only once the company has actual nexus (typically an employee in the state) — locations alone are insufficient. With both employees in place, the StateTaxesForm applicable_if e2e specs can hit the real /tax_requirements/{WA,ID} endpoints and exercise conditional visibility on whatever defaults the demo provides.",
+ "domain": "company",
+ "baseDemo": "react_sdk_demo",
+ "decorations": {
+ "locations": [
+ {
+ "key": "wa",
+ "street_1": "1700 7th Ave",
+ "city": "Seattle",
+ "state": "WA",
+ "zip": "98101",
+ "filing_address": true,
+ "mailing_address": true
+ },
+ {
+ "key": "id",
+ "street_1": "150 N 8th St",
+ "city": "Boise",
+ "state": "ID",
+ "zip": "83702"
+ }
+ ],
+ "stateTaxes": [
+ {
+ "state": "WA",
+ "requirementSets": [
+ {
+ "key": "taxrates",
+ "requirements": [{ "key": "usedefaultsuirates", "value": true }]
+ }
+ ]
+ },
+ {
+ "state": "ID",
+ "requirementSets": [
+ {
+ "key": "taxrates",
+ "requirements": [{ "key": "suireimbursable", "value": false }]
+ }
+ ]
+ }
+ ],
+ "employees": [
+ {
+ "key": "wa_employee",
+ "first_name": "Wendy",
+ "last_name": "Washington",
+ "home_address": {
+ "street_1": "1700 7th Ave",
+ "city": "Seattle",
+ "state": "WA",
+ "zip": "98101"
+ },
+ "work_address": { "locationKey": "wa" },
+ "job": {
+ "title": "Engineer",
+ "hire_date": "2026-01-01",
+ "locationKey": "wa"
+ }
+ },
+ {
+ "key": "id_employee",
+ "first_name": "Ivan",
+ "last_name": "Idaho",
+ "home_address": {
+ "street_1": "150 N 8th St",
+ "city": "Boise",
+ "state": "ID",
+ "zip": "83702"
+ },
+ "work_address": { "locationKey": "id" },
+ "job": {
+ "title": "Engineer",
+ "hire_date": "2026-01-01",
+ "locationKey": "id"
+ }
+ }
+ ]
+ },
+ "expectedContext": [
+ "companyId",
+ "locationIds.wa",
+ "locationIds.id",
+ "employeeIds.wa_employee",
+ "employeeIds.id_employee"
+ ]
+}
diff --git a/e2e/tests/company/03-state-taxes-applicable-if.spec.ts b/e2e/tests/company/03-state-taxes-applicable-if.spec.ts
new file mode 100644
index 000000000..0839bff10
--- /dev/null
+++ b/e2e/tests/company/03-state-taxes-applicable-if.spec.ts
@@ -0,0 +1,68 @@
+import { test, expect } from '../../utils/localTestFixture'
+import { waitForLoadingComplete } from '../../utils/helpers'
+
+// Real-backend smoke for StateTaxesForm.
+//
+// The PR adds two filters to the State Taxes form: drop non-editable
+// requirements, and gate the rest via the API's `applicable_if` contract.
+// Both have thorough unit coverage in:
+// - src/.../applicableIf.test.ts (helper)
+// - src/.../StateTaxesForm.test.tsx (render + submit, MSW-driven WA fixture
+// with the parent `usedefaultsuirates` radio and gated children).
+//
+// On the real demo backend, the WA/ID `taxrates` sets surface only the
+// resolved leaf requirements — Gusto's prepare_requirements collapses
+// applicable_if when the underlying state filing requirement has the gate
+// already resolved, which is the demo factory's default. So these specs can
+// only assert what the demo actually exposes: that StateTaxesForm renders
+// the discovered requirements end-to-end against the live backend. The
+// scenario-runner's `stateTaxes` decoration (added alongside this spec)
+// stays in place so future demo states that DO expose gating can be tested
+// here without further plumbing work.
+test.describe('StateTaxesForm — real-backend rendering', () => {
+ test.beforeEach(({}, testInfo) => {
+ testInfo.annotations.push({
+ type: 'scenario',
+ description: 'shared/state-taxes-wa-id',
+ })
+ })
+
+ test('WA: renders the tax requirements returned by the real backend', async ({
+ page,
+ scenario,
+ }) => {
+ test.skip(!scenario.flowToken, 'Requires scenario provisioning (real-backend CI/local runs)')
+
+ await page.goto('/?flow=state-taxes-form&state=WA')
+ await waitForLoadingComplete(page, {
+ timeout: 30_000,
+ anchor: page.getByRole('heading', { name: /tax rates/i }).first(),
+ })
+
+ // From the discovery dump: WA taxrates exposes UI rate (6ee9787b…)
+ // and EAF rate (d312425d…) on this demo. Their labels are pinned by
+ // the gws-flows tax_requirements catalog.
+ await expect(page.getByText('Unemployment Insurance Rate', { exact: true })).toBeVisible()
+ await expect(page.getByText('EAF Tax Rate', { exact: true })).toBeVisible()
+ await expect(page.getByRole('button', { name: /Save/i })).toBeVisible()
+ })
+
+ test('ID: renders the tax requirements returned by the real backend', async ({
+ page,
+ scenario,
+ }) => {
+ test.skip(!scenario.flowToken, 'Requires scenario provisioning (real-backend CI/local runs)')
+
+ await page.goto('/?flow=state-taxes-form&state=ID')
+ await waitForLoadingComplete(page, {
+ timeout: 30_000,
+ anchor: page.getByRole('heading', { name: /tax rates/i }).first(),
+ })
+
+ // ID taxrates exposes the three rate fields per ZP's tax_profiles
+ // by_tax_agency/id_spec: UI Contribution, Administrative Reserve,
+ // Workforce Development.
+ await expect(page.getByText(/UI Contribution Rate/i).first()).toBeVisible()
+ await expect(page.getByRole('button', { name: /Save/i })).toBeVisible()
+ })
+})
diff --git a/src/components/Company/StateTaxes/StateTaxesForm/Form.tsx b/src/components/Company/StateTaxes/StateTaxesForm/Form.tsx
index 1012eb80a..12fabb40b 100644
--- a/src/components/Company/StateTaxes/StateTaxesForm/Form.tsx
+++ b/src/components/Company/StateTaxes/StateTaxesForm/Form.tsx
@@ -1,7 +1,9 @@
import { useTranslation } from 'react-i18next'
import { Fragment } from 'react/jsx-runtime'
+import { useFormContext, useWatch } from 'react-hook-form'
import { useStateTaxesForm } from './context'
import { toRhfKey } from './rhfKey'
+import { isRequirementApplicable, type StateTaxesFormValues } from './applicableIf'
import { QuestionInput } from '@/components/Common/TaxInputs/TaxInputs'
import { useLocaleDateFormatter } from '@/contexts/LocaleProvider/useLocale'
import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext'
@@ -12,6 +14,8 @@ export function Form() {
const dateFormatter = useLocaleDateFormatter()
const { stateTaxRequirements } = useStateTaxesForm()
const Components = useComponentContext()
+ const { control } = useFormContext()
+ const watchedValues = useWatch({ control }) as StateTaxesFormValues
return stateTaxRequirements.requirementSets?.map(
({ requirements, label, effectiveFrom, key }) => (
@@ -24,8 +28,10 @@ export function Form() {
)}
- {requirements?.map(requirement => {
- return (
+ {requirements?.flatMap(requirement => {
+ if (requirement.editable === false) return []
+ if (!key || !isRequirementApplicable(requirement, key, watchedValues)) return []
+ return [
- )
+ />,
+ ]
})}
),
diff --git a/src/components/Company/StateTaxes/StateTaxesForm/StateTaxesForm.test.tsx b/src/components/Company/StateTaxes/StateTaxesForm/StateTaxesForm.test.tsx
index 722cf8a36..0a8d47658 100644
--- a/src/components/Company/StateTaxes/StateTaxesForm/StateTaxesForm.test.tsx
+++ b/src/components/Company/StateTaxes/StateTaxesForm/StateTaxesForm.test.tsx
@@ -72,7 +72,16 @@ describe('StateTaxesForm', () => {
})
})
- it('renders tax rate fields', async () => {
+ it('hides applicable_if-gated rate fields until the radio is toggled', async () => {
+ const useDefaultRadioYes = await screen.findByRole('radio', { name: /^Yes$/ })
+ expect(useDefaultRadioYes).toBeChecked()
+ expect(screen.queryByLabelText(/Unemployment Insurance Rate/i)).toBeNull()
+
+ const useDefaultRadioNo = screen.getByRole('radio', {
+ name: /No, my agency gave me new rates/i,
+ })
+ await user.click(useDefaultRadioNo)
+
await waitFor(() => {
expect(screen.getByLabelText(/Unemployment Insurance Rate/i)).toBeInTheDocument()
})
@@ -98,6 +107,43 @@ describe('StateTaxesForm', () => {
})
})
+ it('does not render non-editable requirements', async () => {
+ await waitFor(() => {
+ expect(screen.getByLabelText(/Unified Business ID/i)).toBeInTheDocument()
+ })
+ expect(screen.queryByLabelText(/Legacy Read-Only Identifier/i)).toBeNull()
+ expect(screen.queryByText(/Legacy Read-Only Identifier/i)).toBeNull()
+ })
+
+ it('omits non-editable requirements from the submit payload', async () => {
+ let capturedBody: unknown = null
+ server.use(
+ http.put(
+ `${API_BASE_URL}/v1/companies/:company_id/tax_requirements/:state`,
+ async ({ request }) => {
+ capturedBody = await request.json()
+ return HttpResponse.json({})
+ },
+ ),
+ )
+
+ const submitButton = await screen.findByRole('button', { name: /Save/i })
+ await user.click(submitButton)
+
+ await waitFor(() => {
+ expect(onEvent).toHaveBeenCalledWith(componentEvents.COMPANY_STATE_TAX_UPDATED)
+ })
+
+ const body = capturedBody as {
+ requirement_sets: Array<{
+ key: string
+ requirements: Array<{ key: string; value: string }>
+ }>
+ }
+ const submittedKeys = body.requirement_sets.flatMap(rs => rs.requirements.map(r => r.key))
+ expect(submittedKeys).not.toContain('legacy_read_only_field')
+ })
+
it('submits workers compensation rate values entered by the user', async () => {
let capturedBody: unknown = null
server.use(
diff --git a/src/components/Company/StateTaxes/StateTaxesForm/StateTaxesForm.tsx b/src/components/Company/StateTaxes/StateTaxesForm/StateTaxesForm.tsx
index 20b79c669..175e08475 100644
--- a/src/components/Company/StateTaxes/StateTaxesForm/StateTaxesForm.tsx
+++ b/src/components/Company/StateTaxes/StateTaxesForm/StateTaxesForm.tsx
@@ -9,7 +9,8 @@ import { Head } from './Head'
import { StateTaxesFormProvider } from './context'
import { Form } from './Form'
import { Actions } from './Actions'
-import { fromRhfKey, toRhfKey } from './rhfKey'
+import { toRhfKey } from './rhfKey'
+import { isRequirementApplicable, type StateTaxesFormValues } from './applicableIf'
import type { BaseComponentInterface } from '@/components/Base/Base'
import { BaseComponent } from '@/components/Base/Base'
import { useI18n } from '@/i18n/I18n'
@@ -163,19 +164,24 @@ function Root({ companyId, state, className, children }: StateTaxesFormProps) {
const onSubmit = async (formData: InferredFormInputs) => {
await baseSubmitHandler(formData, async payload => {
+ const formValues = payload as StateTaxesFormValues
const requirementSets = stateTaxRequirements.requirementSets
?.filter(rs => rs.key && payload[rs.key])
.map(requirementSet => {
const requirementSetKey = requirementSet.key as string
const payloadSet = payload[requirementSetKey] as Record
+ const applicableRequirements = (requirementSet.requirements ?? [])
+ .filter(req => req.editable !== false)
+ .filter(req => isRequirementApplicable(req, requirementSetKey, formValues))
+
return {
state,
key: requirementSetKey,
effectiveFrom: requirementSet.effectiveFrom,
- requirements: Object.entries(payloadSet).map(([reqKey, value]) => ({
- key: fromRhfKey(reqKey),
- value: stringifyRequirementValue(value),
+ requirements: applicableRequirements.map(req => ({
+ key: req.key as string,
+ value: stringifyRequirementValue(payloadSet[toRhfKey(req.key as string)]),
})),
}
})
diff --git a/src/components/Company/StateTaxes/StateTaxesForm/applicableIf.test.ts b/src/components/Company/StateTaxes/StateTaxesForm/applicableIf.test.ts
new file mode 100644
index 000000000..0cda21439
--- /dev/null
+++ b/src/components/Company/StateTaxes/StateTaxesForm/applicableIf.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it } from 'vitest'
+import { isRequirementApplicable, type StateTaxesFormValues } from './applicableIf'
+import type { TaxRequirement } from '@gusto/embedded-api-v-2025-11-15/models/components/taxrequirement'
+
+const req = (overrides: Partial = {}): TaxRequirement => ({
+ key: 'rate',
+ applicableIf: [],
+ ...overrides,
+})
+
+describe('isRequirementApplicable', () => {
+ it('returns true when applicableIf is missing', () => {
+ expect(isRequirementApplicable(req({ applicableIf: undefined }), 'taxrates', {})).toBe(true)
+ })
+
+ it('returns true when applicableIf is empty', () => {
+ expect(isRequirementApplicable(req({ applicableIf: [] }), 'taxrates', {})).toBe(true)
+ })
+
+ it('returns true when a single constraint matches the form value', () => {
+ const formValues: StateTaxesFormValues = {
+ taxrates: { usedefaultsuirates: false },
+ }
+ const requirement = req({
+ applicableIf: [{ key: 'usedefaultsuirates', value: false }],
+ })
+ expect(isRequirementApplicable(requirement, 'taxrates', formValues)).toBe(true)
+ })
+
+ it('returns false when a single constraint does not match', () => {
+ const formValues: StateTaxesFormValues = {
+ taxrates: { usedefaultsuirates: true },
+ }
+ const requirement = req({
+ applicableIf: [{ key: 'usedefaultsuirates', value: false }],
+ })
+ expect(isRequirementApplicable(requirement, 'taxrates', formValues)).toBe(false)
+ })
+
+ it('requires every constraint to match (AND-logic)', () => {
+ const formValues: StateTaxesFormValues = {
+ taxrates: { suireimbursable: false, usedefaultsuirates: false },
+ }
+ const requirement = req({
+ applicableIf: [
+ { key: 'suireimbursable', value: false },
+ { key: 'usedefaultsuirates', value: false },
+ ],
+ })
+ expect(isRequirementApplicable(requirement, 'taxrates', formValues)).toBe(true)
+
+ const partialMatch: StateTaxesFormValues = {
+ taxrates: { suireimbursable: false, usedefaultsuirates: true },
+ }
+ expect(isRequirementApplicable(requirement, 'taxrates', partialMatch)).toBe(false)
+ })
+
+ it('returns false when the requirement set has no form values yet', () => {
+ const requirement = req({
+ applicableIf: [{ key: 'usedefaultsuirates', value: false }],
+ })
+ expect(isRequirementApplicable(requirement, 'taxrates', {})).toBe(false)
+ })
+
+ it('round-trips constraint keys through toRhfKey', () => {
+ const formValues: StateTaxesFormValues = {
+ taxrates: { wa_wc_hourly_rate__PIPE__010103: true },
+ }
+ const requirement = req({
+ applicableIf: [{ key: 'wa_wc_hourly_rate|010103', value: true }],
+ })
+ expect(isRequirementApplicable(requirement, 'taxrates', formValues)).toBe(true)
+ })
+})
diff --git a/src/components/Company/StateTaxes/StateTaxesForm/applicableIf.ts b/src/components/Company/StateTaxes/StateTaxesForm/applicableIf.ts
new file mode 100644
index 000000000..0a01d6e15
--- /dev/null
+++ b/src/components/Company/StateTaxes/StateTaxesForm/applicableIf.ts
@@ -0,0 +1,28 @@
+import type { TaxRequirement } from '@gusto/embedded-api-v-2025-11-15/models/components/taxrequirement'
+import { toRhfKey } from './rhfKey'
+
+/**
+ * Map of `applicable_if` set key to its values, keyed by RHF-encoded field key.
+ * Internal shape used by {@link isRequirementApplicable}.
+ *
+ * @internal
+ */
+export type StateTaxesFormValues = Record | undefined>
+
+/** @internal */
+export function isRequirementApplicable(
+ requirement: TaxRequirement,
+ setKey: string,
+ formValues: StateTaxesFormValues,
+): boolean {
+ const constraints = requirement.applicableIf
+ if (!constraints || constraints.length === 0) return true
+
+ const setValues = formValues[setKey]
+ if (!setValues) return false
+
+ return constraints.every(({ key, value }) => {
+ if (!key) return true
+ return setValues[toRhfKey(key)] === value
+ })
+}
diff --git a/src/test/mocks/apis/company_state_taxes.ts b/src/test/mocks/apis/company_state_taxes.ts
index 4a338b341..1402b2800 100644
--- a/src/test/mocks/apis/company_state_taxes.ts
+++ b/src/test/mocks/apis/company_state_taxes.ts
@@ -6,9 +6,12 @@ export const getStateTaxRequirements = http.get(
`${API_BASE_URL}/v1/companies/:company_id/tax_requirements/:state`,
async ({ params }) => {
const state = params.state as string
- const GAFixture = await getFixture('get-v1-companies-company_id-tax_requirements-GA')
- const WAFixture = await getFixture('get-v1-companies-company_id-tax_requirements-WA')
- return HttpResponse.json(state === 'WA' ? WAFixture : GAFixture)
+ const fixtureName = `get-v1-companies-company_id-tax_requirements-${state}`
+ try {
+ return HttpResponse.json(await getFixture(fixtureName))
+ } catch {
+ return HttpResponse.json(await getFixture('get-v1-companies-company_id-tax_requirements-GA'))
+ }
},
)
diff --git a/src/test/mocks/fixtures/get-v1-companies-company_id-tax_requirements-ID.json b/src/test/mocks/fixtures/get-v1-companies-company_id-tax_requirements-ID.json
new file mode 100644
index 000000000..1f3d615a6
--- /dev/null
+++ b/src/test/mocks/fixtures/get-v1-companies-company_id-tax_requirements-ID.json
@@ -0,0 +1,106 @@
+{
+ "company_uuid": "1a5edee4-7672-40f7-afdf-0483f396b8d1",
+ "state": "ID",
+ "requirement_sets": [
+ {
+ "state": "ID",
+ "key": "registrations",
+ "label": "Department of Labor",
+ "effective_from": null,
+ "requirements": [
+ {
+ "key": "id_ui_account_number",
+ "applicable_if": [],
+ "label": "Idaho UI Account Number",
+ "description": "You receive this number after registering with the Idaho Department of Labor.",
+ "value": null,
+ "metadata": {
+ "type": "account_number",
+ "mask": null,
+ "prefix": null
+ },
+ "editable": true
+ }
+ ]
+ },
+ {
+ "state": "ID",
+ "key": "taxrates",
+ "label": "Tax Rates",
+ "effective_from": "2025-01-01",
+ "requirements": [
+ {
+ "key": "suireimbursable",
+ "applicable_if": [],
+ "label": "Are you a reimbursable employer for unemployment insurance?",
+ "description": "Reimbursable employers reimburse the state for unemployment benefits paid out rather than paying SUI tax.",
+ "value": false,
+ "metadata": {
+ "type": "radio",
+ "options": [
+ {
+ "label": "Yes, we're a reimbursable employer",
+ "short_label": "Yes",
+ "value": true
+ },
+ {
+ "label": "No, we pay SUI tax",
+ "short_label": "No",
+ "value": false
+ }
+ ]
+ },
+ "editable": true
+ },
+ {
+ "key": "id_ui_contribution_rate",
+ "applicable_if": [
+ {
+ "key": "suireimbursable",
+ "value": false
+ }
+ ],
+ "label": "UI Contribution Rate",
+ "description": "Your assigned UI contribution rate from the Idaho Department of Labor.",
+ "value": "0.01",
+ "metadata": {
+ "type": "tax_rate"
+ },
+ "editable": true
+ },
+ {
+ "key": "id_admin_rate",
+ "applicable_if": [
+ {
+ "key": "suireimbursable",
+ "value": false
+ }
+ ],
+ "label": "Administrative Reserve Rate",
+ "description": "Your assigned administrative reserve rate from the Idaho Department of Labor.",
+ "value": "0.0002",
+ "metadata": {
+ "type": "tax_rate"
+ },
+ "editable": true
+ },
+ {
+ "key": "id_workforce_rate",
+ "applicable_if": [
+ {
+ "key": "suireimbursable",
+ "value": false
+ }
+ ],
+ "label": "Workforce Development Rate",
+ "description": "Your assigned workforce development training fund rate from the Idaho Department of Labor.",
+ "value": "0.0003",
+ "metadata": {
+ "type": "tax_rate"
+ },
+ "editable": true
+ }
+ ]
+ }
+ ]
+}
diff --git a/src/test/mocks/fixtures/get-v1-companies-company_id-tax_requirements-WA.json b/src/test/mocks/fixtures/get-v1-companies-company_id-tax_requirements-WA.json
index 5b8fc42b0..83692ce7f 100644
--- a/src/test/mocks/fixtures/get-v1-companies-company_id-tax_requirements-WA.json
+++ b/src/test/mocks/fixtures/get-v1-companies-company_id-tax_requirements-WA.json
@@ -59,6 +59,17 @@
"prefix": null
},
"editable": true
+ },
+ {
+ "key": "legacy_read_only_field",
+ "applicable_if": [],
+ "label": "Legacy Read-Only Identifier",
+ "description": "A non-editable identifier the API surfaces but the user cannot change.",
+ "value": "READONLY-VALUE-123",
+ "metadata": {
+ "type": "text"
+ },
+ "editable": false
}
]
},