- Not a DB column - delegated from
PayrollReferenceData::Tax(JSON reference data) - Type:
T::Boolean, most taxes havefalse
Federal (5 taxes) - All Employer Social Security Credits (COVID-related):
00-000-0000-ER_CRED-001- Emergency Leave, Caring for Self00-000-0000-ER_CRED-002- Emergency Leave, Caring for Others00-000-0000-ER_CRED-003- Family Medical Leave00-000-0000-ER_CRED-004- Employee Retention00-000-0000-ER_CRED-005- COVID-19 Relief
State (5 taxes across 4 states):
- IL:
17-000-0000-ER_CRED-000(Employer Credit) - GA:
13-000-0000-ER_CRED-000(Employer Credit) - NM:
35-000-0000-WC-000(Workers' Compensation) - employee-level, see below - MO:
29-000-0000-SITCD-000-CD05(Compensation Deduction) - company-level, see below - MO:
29-000-0000-SITCD-000-CD2(Compensation Deduction) - company-level, see below
Pattern: All are credits/deductions that legitimately go negative. Different record types handle them differently.
MO SITCD is NOT a PayrollItemTax - it's a CompanyTax:
- Created in
CompanyTaxes::CompanyTaxHandling::CompensationDeductionTax - Employers retain 0.5-2% of collected SIT as compensation for withholding
- Always negative amounts (reduces what employer owes to state)
employee_ids: []- not tied to specific employees
Why this matters for YTD validation:
PayrollItemTax#ytd_values_are_positivevalidation only runs onPayrollItemTaxrecordsCompanyTaxis a different model with no YTD validation- SITCD bypasses the YTD check entirely by being company-level
Two tax record types:
| Model | Level | Examples | Has YTD validation? |
|---|---|---|---|
PayrollItemTax |
Employee | SIT, FICA, NM WC | Yes |
CompanyTax |
Company | MO SITCD, MCTMT | No |
35-000-0000-WC-000hasallow_negative_amount: true- OR WC (
41-000-0000-WC-000) hasallow_negative_amount: false - Both are employee-side WC taxes (
employer_tax: false) - Git history: Original commit (02c0fdfb9e733, Feb 2017) shows NM WC was
trueand OR WC wasnullfrom initial import.
Why NM WC needs negative amounts: Found hardcoded exception in PayrollItemTax YTD/QTD validation (lines 169, 203) with comment:
"NM workers comp is often withheld with a check date AFTER the relevant EOQ. So if you have a liability on July 10, and we want to remove it (because, say, you actually quit on June 20), then we want to make a June 30 negative liability (which is illegal according to this rule)"
Three levels of negative amount handling:
allow_negative_amount: true→ allows negative current-period amountscredit_tax?(ER_CRED) → also exempt from YTD/QTD positive checks- Hardcoded NM WC exception → also exempt from YTD/QTD checks (timing edge case)
Conclusion: NM WC's allow_negative_amount: true is intentional - needed for backdated corrections when check dates cross quarter boundaries. Not a compliance question.
allow_negative_amount flag usages:
PayrollTax#allow_negative_amounts?- Combined with payroll type checks (correction/recon/reversal)PayrollItemTax#allow_negative_amounts?- Same patternPayrollTax#can_negative_tax_be_marked_as_prepaid?- Direct check on taxUpdatePrepaidPayrollTaxes- Clamps negative amounts to 0 unless flag is true
credit_tax? method usages (separate from allow_negative_amount):
PayrollItemTaxYTD/QTD validation - Credit taxes exempt from positive checkPayrollItem#allow_negative_employer_tax?- Additional escape hatchFEIN Change- Credit taxes excluded from paystubsBookkeeping- Credit taxes excluded from employer tax ledger entries
Key insight: credit_tax? is hardcoded to tax_type_code == 'ER_CRED'. It provides stricter handling than just allow_negative_amount.
- Name is clear and descriptive
- Accurately reflects behavior (allowing negative amounts on tax records)
Can this be derived instead of stored? Partially.
9/10 taxes with allow_negative_amount: true follow the pattern: tax_type_code in ['ER_CRED', 'SITCD']. The exception is NM WC, which needs negative amounts for timing edge cases (not because it's a credit).
Option A: STE type check + hardcode
def allows_negative_amount?
tax_type_code.in?(['ER_CRED', 'SITCD']) || tax_id == Tax::NM_WC_TAX_ID
end- Pros: Explicit, auditable, matches existing
credit_tax?pattern - Cons: Requires code change for new credit types
Option B: Keep as explicit attribute
- Pros: Flexible, no hardcodes needed
- Cons: Another attribute to maintain in reference data
Recommendation: Option A (derive from type + hardcode NM WC). The attribute is only used in 3 places, NM WC is already hardcoded elsewhere, and deriving simplifies reference data.
- Not a DB column - delegated from
PayrollReferenceData::Tax(JSON reference data) - Source:
zp_tax_groupin state JSON files,tax_groupin federal JSON file - Type:
String- contains aunique_tax_idof another tax (or itself)
Groups related taxes together for various purposes. A tax's tax_group points to the "primary" tax in its family.
Total taxes: 7,087
| Category | Count | % |
|---|---|---|
Self-referential (tax_group == unique_tax_id) |
6,097 | 86% |
| Pointing elsewhere | 990 | 14% |
Federal: 58 of 63 (92%) point elsewhere State: 932 of 7,024 (13%) point elsewhere
| Pattern | Count | % | Why grouped |
|---|---|---|---|
| Ohio Vol (voluntary) | 669 | 67.6% | Single exemption controls both mandatory and voluntary |
| NonRes/Res | 242 | 24.4% | Same tax, different residency status |
| State FUTA credits | 51 | 5.2% | Report under unified FUTA |
| ER_CRED (credits) | 7 | 0.7% | Offset FICA liabilities |
| ER/EE pairs | 2 | 0.2% | Sync exemptions, sum amounts |
| Other variants | 19 | 1.9% | Zone, district, ordinal suffixes |
1. Ohio Vol (voluntary) - 669 taxes
39-000-1037396-CITY-000-Vol → 39-000-1037396-CITY-000
Ohio cities with optional enrollment. The -Vol variant groups under the base city tax.
2. NonRes/Res - 242 taxes
18-001-0000-CNTY-000-NonRes → 18-001-0000-CNTY-000
Indiana county tax for non-residents groups under the base county tax. Indiana is systematic: 184 of 186 taxes (98.9%) follow this pattern.
3. State FUTA credits - 51 taxes
00-000-0000-ER_FUTA-000-AL → 00-000-0000-ER_FUTA-000
Each state's FUTA credit reduction groups under the base federal FUTA. All 50 states + DC follow this pattern.
4. ER_CRED (credits) - 7 taxes
00-000-0000-ER_CRED-001 → 00-000-0000-FICA-000
Federal COVID emergency leave credit (caring for self) groups under FICA because it offsets FICA liability. All 5 federal + 2 state credits follow this pattern.
5. ER/EE pairs - 2 taxes
00-000-0000-ER_FICA-000 → 00-000-0000-FICA-000
Employer Social Security groups under Employee Social Security. Same for Medicare. Enables synced exemptions and combined reporting.
6. Other variants - 19 taxes
21-111-0000-OLTS-000-Ord → 21-111-0000-OLTS-000
Kentucky occupational license tax with ordinal suffix. Also includes zone/district variants in NY, OH.
| State | Total Taxes | Grouped | % | Primary Pattern |
|---|---|---|---|---|
| Ohio | 1,683 | 671 | 39.9% | Vol (voluntary) |
| Indiana | 186 | 184 | 98.9% | NonRes/Res |
| Michigan | 51 | 48 | 94.1% | NonRes/Res |
| Pennsylvania | 4,592 | 3 | 0.07% | Minimal grouping |
| Kentucky | 271 | 6 | 2.2% | Mixed |
Notable: Pennsylvania has the most granular tax database (65% of all state taxes) but uses grouping the least.
- No multi-level indirection - All tax_group references are 1-level deep
- ER/EE pairs are rare - Only 2 taxes (FICA, MEDI) despite code being designed for this pattern
Primary uses (21 files reference tax_group):
| # | Usage | What it does | Why tax_group matters |
|---|---|---|---|
| 1 | Tax Exemptions | UI shows one tax per group; exempting one auto-exempts others | Keeps grouped taxes exempt as one unit |
| 2 | Historical Tax Liability | Stores liabilities at tax_group level; payment splitting matches payroll_item_taxes by tax_group | Matches payments to tax family |
| 3 | Cash Requirements Report | Groups taxes via total_tax_amount_for_ee/er(tax_group) |
One line per tax family |
The exemption UI deduplicates taxes by tax_group so users see one entry per tax family (e.g., "Social Security" rather than separate EE/ER entries). When a user exempts one tax, the TaxChangesGroupable concern automatically creates matching exemptions for all other taxes in the same group via an after_save callback.
Key code:
- UI deduplication:
packs/.../tax_exemption/app/public/tax_exemption/graphql_resolvers/read/get_applicable_taxes_for_employee.rb:55-60- groups bytax_group, picks first exemptable tax - Auto-sync callback:
packs/.../tax_exemption/app/models/concerns/tax_changes_groupable.rb:15-38- mirrors exemptions to related taxes - Find related taxes:
components/deprecated_legacy_reference_data/lib/payroll_reference_data/tax_exemptions.rb:10-14-PayrollReferenceData::Tax.from_tax_group(tax.tax_group)
The historical tax liability system uses tax_group throughout its entire lifecycle to consolidate related taxes (like Social Security EE and ER) into a single liability unit. The HistoricalTaxLiability table stores one record per tax_group per company, with the tax_id pointing to the "default" tax (typically the employee variant). All amount calculations and payment matching then filter by tax_group to capture all related taxes.
Liability creation: When converting historical payrolls, the system extracts unique tax_group values, then creates ONE HistoricalTaxLiability per group using find_default_tax(tax_group) to pick the canonical tax.
Amount calculations: Methods like total_ytd_liability, paid_liability, and unpaid_liability all filter payroll taxes by matching tax_group, ensuring both EE and ER portions contribute to the same liability.
Payment matching: When building debits, the system finds undebited payroll taxes by tax_group, groups them by check date, and creates debit entries that map payments to specific historical payrolls.
Key code:
- Liability creation:
packs/.../historical_payrolls/app/services/historical_companies/convert_historical_payrolls.rb:67-89- extracts tax_groups, creates one liability per group - Default tax resolution:
packs/.../historical_payrolls/app/services/historical_payrolls/historical_tax_liability_finder.rb:37-39-find_default_tax(tax_group) - Amount calculations:
packs/.../historical_payrolls/app/models/historical_tax_liability.rb:137,163,177- filters bytax_groupintotal_ytd_liability,paid_liability,unpaid_liability - Finding undebited taxes:
packs/.../historical_payrolls/app/services/payments/debits/historical/undebited_payroll_taxes_finder.rb:47-payroll_tax.tax.tax_group == tax.tax_group - Debit building:
packs/.../historical_payrolls/app/services/payments/debits/historical/historical_tax_liability_debit_builder.rb:45-47- groups bytax_group
The Cash Requirements Report outputs one line per tax family by iterating over unique tax_group values and summing employee/employer amounts separately. This is the only caller of total_tax_amount_for_ee/er(tax_group) methods.
Key code:
- CSV report:
packs/.../payroll_reports/app/services/customer_reports/reports/cash_requirements.rb:180-187- iteratespayroll_item_taxes.map(&:tax_group).uniq - PDF report:
packs/.../reporting_toolkit/reports/app/views/customer_reports/pdf/cash_requirements.arb:188-190 - Calculator methods:
packs/.../running_payroll/app/services/calculators/payroll_calculator.rb:178-191-total_tax_amount_for_ee/er
The code was designed for the EE/ER pattern (e.g., Social Security EE + ER in same group), but only 2 of 990 grouped taxes actually follow this pattern:
| Pattern | Count | % | Both apply to same employee? |
|---|---|---|---|
| Ohio Vol (voluntary) | 669 | 67.6% | No - mutually exclusive |
| NonRes/Res | 242 | 24.4% | No - mutually exclusive |
| State FUTA variants | 51 | 5.2% | No - one per state |
| ER_CRED credits | 7 | 0.7% | No - only when credit applies |
| EE/ER pairs | 2 | 0.2% | Yes - both on every payroll |
| Other | 19 | 1.9% | Varies |
Implication: The code handles the dominant patterns (Vol, NonRes/Res) correctly despite being designed for the rare pattern (EE/ER).
Examined whether the Res/NonRes pattern causes issues with code designed for EE/ER:
Example: Walker City Tax
26-000-1615688-CITY-000-Res(exemptable: true, employer_tax: false)26-000-1615688-CITY-000-NonRes(exemptable: true, employer_tax: false)- Both point to tax_group
26-000-1615688-CITY-000
Key finding: The code is robust because all lookups use tax_group matching:
# Historical liability lookup (undebited_payroll_taxes_finder.rb:47)
payroll_taxes.select { |pt| pt.tax.tax_group == tax.tax_group }This finds ALL taxes in the group, not just the one stored on the liability.
| Operation | How it works | Res/NonRes safe? |
|---|---|---|
| Store liability | Against one "default" tax | N/A |
| Calculate amounts | WHERE tax.tax_group == X |
✅ Finds both |
| Find undebited taxes | WHERE tax.tax_group == X |
✅ Finds both |
| Payment splitting | WHERE tax.tax_group == X |
✅ Finds both |
Why it works: An employee can only be Res OR NonRes, never both. So grouping them doesn't cause double-counting.
Minor issues (cosmetic):
find_default_taxpicks arbitrarily when both areemployer_tax: false- Comment says "we choose the EE tax" but for city taxes, both are EE-side
- UI might show "NonRes" variant when data is actually for "Res"
Conclusion: tax_group abstraction designed for EE/ER accidentally works for Res/NonRes because the lookup pattern is general enough.
Seems like a lot of the current grouping was not done by-hand by a human, but rather ported over from a previous implementation (https://github.com/Gusto/zenpayroll/pull/42334). The usages are a little sus (particularly Historical Tax Liability), I'm not convinced they're doing the right thing for all the patterns that exist. I'm not sure if there's something here that's worth preserving the ability to manage by hand, or if we should work towards deriving this attribute. Will circle back.
The "exemptable" attribute controlls whether a tax exemption can be created for a specific tax. Only 69 taxes (out of ~7K, so 1%) are non-exemptable, everything else is exemptable.
| Category | Exemptable | Non-Exemptable | % Exemptable |
|---|---|---|---|
| Federal | 5 | 58 | 8% |
| State | 7,013 | 11 | 99.8% |
| Total | ~7,018 | ~69 | 99% |
Federal exemptable taxes (only 5):
FIT-000(Federal Income Tax)FICA-000(Social Security - Employee)ER_FICA-000(Social Security - Employer)MEDI-000(Medicare - Employee)ER_FUTA-000(Federal Unemployment - base only, no state modifier)
Federal non-exemptable (58):
MEDI2-000(Additional Medicare)ER_MEDI-000(Employer Medicare)- All 51
ER_FUTA-000-XXstate credit variants - All 5
ER_CRED-00XCOVID credits
State non-exemptable (11):
13-000-0000-ER_CRED-000(GA - Employer Credit)17-000-0000-ER_CRED-000(IL - Employer Credit)29-000-0000-SITCD-000-CD05(MO - SIT Compensation Deduction)29-000-0000-SITCD-000-CD2(MO - SIT Compensation Deduction)34-000-0000-ER_SUTA_SC-048(NJ - SUTA Surcharge)41-000-0000-ER_TRANS-001(OR - Employer Transit)41-000-0000-ER_TRANS-002(OR - Employer Transit)41-000-0000-ER_TRANS-003(OR - Employer Transit)41-000-0000-ER_TRANS-004(OR - Employer Transit)41-000-0000-ER_TRANS-005(OR - Employer Transit)41-000-0000-ER_TRANS-006(OR - Employer Transit)
| # | Usage | What it does | Key code |
|---|---|---|---|
| 1 | Exemption validation | Prevents creating exemptions for non-exemptable taxes | exempt_tax.rb:80, company_exempt_tax.rb:60 |
| 2 | UI filtering | Only shows exemptable taxes in exemption UI | get_applicable_taxes_for_employee.rb:57 |
| 3 | GraphQL exposure | Exposes exemptable field on Tax type |
objects/tax.rb |
Hardcoded validations in CompanyExemptTax:
-
Companies cannot be FICA-exempt (
company_exempt_tax.rb:65-68)def cannot_be_fica_exempt errors.add(:tax, 'Companies cannot be exempt from FICA taxes') if tax&.fica_tax? end
-
Only non-profits can be FUTA-exempt at company level (
company_exempt_tax.rb:71-74)def only_non_profit_can_be_futa_exempt if tax&.federal_unemployment_tax? && company.tax_payer_type != TAX_PAYER_TYPE_NON_PROFIT errors.add(:tax, 'Only Non-Profits can be FUTA exempt') end end
-
Employee FICA exemptions exist for specific scenarios (documented in
get_tax_exemptability_for_employee.rb:8-43):- Students working at same institution
- Nonresident aliens (F1, J1 visa holders)
- H-1B visa holders under totalization agreements
Origin (May 2012) - Commit b8201698b70bb by Edward Kim first added exemptable:
| Tax | Original (2012) | Current |
|---|---|---|
| FIT | false |
true |
| FICA | true |
true |
| Medicare | true |
true |
| FUTA | true |
true |
| CA SIT | false |
true |
FIT changed from non-exemptable to exemptable sometime before the JSON migration.
Federal non-exemptable (58 taxes):
| Tax | Why non-exemptable |
|---|---|
MEDI2-000 (Additional Medicare) |
Surcharge on high earners, calculated on base |
ER_MEDI-000 (Employer Medicare) |
Employer side not exemptable (unlike ER_FICA) |
51 ER_FUTA-000-XX state credits |
Adjustments to base FUTA, not standalone taxes |
5 ER_CRED-00X COVID credits |
Credits reduce liability, can't "exempt" a credit |
State non-exemptable (11 taxes):
| Tax | State | Why non-exemptable |
|---|---|---|
ER_CRED-000 |
GA, IL | Credits, not taxes |
SITCD-000-CD05, SITCD-000-CD2 |
MO | Compensation deduction (employer keeps portion) |
ER_SUTA_SC-048 |
NJ | Surcharge on base SUTA |
ER_TRANS-001 to -006 |
OR | Transit district taxes |
Categories of non-exemptable taxes:
- Credits (
ER_CRED,SITCD) - Can't exempt what reduces your liability - Surcharges (
MEDI2,ER_SUTA_SC) - Calculated on base tax, follows base - Adjustments (
ER_FUTA-XXstate credits) - Not standalone, modifies base - Employer-only non-exemptable (
ER_MEDI,ER_TRANS) - EE side exemptable, ER side not
Some taxes have an asymmetry where the employee side is exemptable but the employer side is not:
| Tax | EE Side | ER Side |
|---|---|---|
| Social Security | exemptable | exemptable |
| Medicare | exemptable | non-exemptable |
| OR Transit | exemptable (TRANS-000) |
non-exemptable (ER_TRANS-001 to -006) |
This pattern appears intentional - the employer portion of these taxes cannot be exempted even when the employee portion can be. Worth confirming with compliance whether this is correct.
Interesting slack conversation about the conditions that lead to employee-exemptability https://gustohq.slack.com/archives/C057108USN8/p1717709170059109
- (capture things to ask compliance partners)