diff --git a/api/_shared.py b/api/_shared.py index 6447f22..a0cc6e2 100644 --- a/api/_shared.py +++ b/api/_shared.py @@ -25,7 +25,10 @@ FILING_STATUS_OPTIONS, HOUSEHOLD_COST_DEFINITIONS, HOUSEHOLD_TYPES, + MAX_ADULTS, + MAX_DEPENDENTS, PROGRAM_DEFINITIONS, + PUBLIC_ASSISTANCE_PROGRAM_OPTIONS, STATE_INFO, ) @@ -45,6 +48,7 @@ def metadata_response() -> dict[str, Any]: "household_types": HOUSEHOLD_TYPES, "programs": PROGRAM_DEFINITIONS, "household_costs": HOUSEHOLD_COST_DEFINITIONS, + "public_assistance_programs": PUBLIC_ASSISTANCE_PROGRAM_OPTIONS, "ccdf_modeled_states": sorted(CCDF_MODELED_STATES), "filing_statuses": FILING_STATUS_OPTIONS, "defaults": { @@ -59,6 +63,9 @@ def metadata_response() -> dict[str, Any]: "series_earnings_buffer": DEFAULT_SERIES_EARNINGS_BUFFER, "series_min_earnings_window": DEFAULT_SERIES_MIN_EARNINGS_WINDOW, "cliff_delta": DEFAULT_CLIFF_DELTA, + "max_adults": MAX_ADULTS, + "max_dependents": MAX_DEPENDENTS, + "programs_mode": "all", }, } diff --git a/cliff_watch/__init__.py b/cliff_watch/__init__.py index f780a90..72e80a9 100644 --- a/cliff_watch/__init__.py +++ b/cliff_watch/__init__.py @@ -13,7 +13,10 @@ FILING_STATUS_OPTIONS, HOUSEHOLD_COST_DEFINITIONS, HOUSEHOLD_TYPES, + MAX_ADULTS, + MAX_DEPENDENTS, PROGRAM_DEFINITIONS, + PUBLIC_ASSISTANCE_PROGRAM_OPTIONS, STATE_INFO, ) @@ -28,6 +31,9 @@ "FILING_STATUS_OPTIONS", "HOUSEHOLD_COST_DEFINITIONS", "HOUSEHOLD_TYPES", + "MAX_ADULTS", + "MAX_DEPENDENTS", "PROGRAM_DEFINITIONS", + "PUBLIC_ASSISTANCE_PROGRAM_OPTIONS", "STATE_INFO", ] diff --git a/cliff_watch/calculator.py b/cliff_watch/calculator.py index adfbdd6..97dae80 100644 --- a/cliff_watch/calculator.py +++ b/cliff_watch/calculator.py @@ -5,7 +5,7 @@ import math import os import sys -from dataclasses import dataclass +from dataclasses import dataclass, replace from pathlib import Path from typing import Any @@ -24,7 +24,10 @@ HOUSEHOLD_COST_DEFINITIONS, MARRIED_FILING_STATUSES, HOUSEHOLD_TYPE_BY_ID, + MAX_ADULTS, + MAX_DEPENDENTS, PROGRAM_DEFINITIONS, + PUBLIC_ASSISTANCE_PROGRAM_OPTIONS, STATE_INFO, STATE_NAME_BY_CODE, STATE_TANF_VARIABLES, @@ -40,6 +43,13 @@ class HouseholdMemberInput: age: int kind: str is_pregnant: bool = False + is_disabled: bool = False + is_blind: bool = False + is_full_time_student: bool = False + is_incapable_of_self_care: bool = False + earned_income: float = 0.0 + ssi_amount: float = 0.0 + ssdi_amount: float = 0.0 @dataclass(frozen=True) @@ -53,12 +63,36 @@ class HouseholdInput: filing_status: str | None = None childcare_expenses: float = 0.0 rent_annual: float = 0.0 + utility_expense_annual: float = 0.0 + food_expense_annual: float = 0.0 + transportation_expense_annual: float = 0.0 + health_insurance_premium_annual: float = 0.0 + technology_expense_annual: float = 0.0 + debt_payment_annual: float = 0.0 + education_expense_annual: float = 0.0 + other_expense_annual: float = 0.0 + self_employment_income_annual: float = 0.0 + child_support_annual: float = 0.0 + taxable_interest_income_annual: float = 0.0 + dividend_income_annual: float = 0.0 + rental_income_annual: float = 0.0 + unemployment_compensation_annual: float = 0.0 + pension_income_annual: float = 0.0 + social_security_annual: float = 0.0 + miscellaneous_income_annual: float = 0.0 + liquid_assets: float = 0.0 + has_employer_health_insurance: bool = False + programs_mode: str = "all" + selected_programs: tuple[str, ...] = () PROGRAM_LABEL_BY_KEY = {item["key"]: item["label"] for item in PROGRAM_DEFINITIONS} HOUSEHOLD_COST_LABEL_BY_KEY = { item["key"]: item["label"] for item in HOUSEHOLD_COST_DEFINITIONS } +PUBLIC_ASSISTANCE_PROGRAM_KEYS = { + item["key"] for item in PUBLIC_ASSISTANCE_PROGRAM_OPTIONS +} FILING_STATUS_CODES = {item["code"] for item in FILING_STATUS_OPTIONS} REFUNDABLE_CREDIT_COMPONENTS = ( {"key": "eitc", "variable": "eitc", "map_to": "tax_unit"}, @@ -177,6 +211,35 @@ def _as_float_list(value: Any) -> list[float]: return [float(value)] +def _nonnegative(value: Any) -> float: + return max(0.0, float(value or 0.0)) + + +def _selected_programs(payload: HouseholdInput) -> set[str]: + return { + key + for key in payload.selected_programs + if key in PUBLIC_ASSISTANCE_PROGRAM_KEYS + } + + +def _program_included(payload: HouseholdInput, key: str) -> bool: + mode = (payload.programs_mode or "all").lower() + if mode == "none": + return False + if mode == "custom": + return key in _selected_programs(payload) + return True + + +def _filtered_program_value( + payload: HouseholdInput, + key: str, + value: float, +) -> float: + return value if _program_included(payload, key) else 0.0 + + def _normalize_county(county: str | None, state: str) -> str | None: if county is None: return None @@ -203,6 +266,12 @@ def _compat_people_from_template( age=int(person["age"]), kind="child" if person["role"] == "dependent" else "adult", is_pregnant=bool(person.get("is_pregnant", False)), + is_disabled=bool(person.get("is_disabled", False)), + is_blind=bool(person.get("is_blind", False)), + is_full_time_student=bool(person.get("is_full_time_student", False)), + is_incapable_of_self_care=bool( + person.get("is_incapable_of_self_care", False) + ), ) for person in template["people"] ) @@ -245,6 +314,15 @@ def _resolved_people(payload: HouseholdInput) -> list[dict[str, Any]]: "kind": member.kind, "age": int(member.age), "is_pregnant": bool(member.is_pregnant), + "is_disabled": bool(member.is_disabled), + "is_blind": bool(member.is_blind), + "is_full_time_student": bool(member.is_full_time_student), + "is_incapable_of_self_care": bool( + member.is_incapable_of_self_care + ), + "earned_income": _nonnegative(member.earned_income), + "ssi_amount": _nonnegative(member.ssi_amount), + "ssdi_amount": _nonnegative(member.ssdi_amount), } ) @@ -324,6 +402,12 @@ def _validate_input(payload: HouseholdInput) -> None: raise ValueError("At least one household member is required") if not any(person.kind == "adult" for person in people): raise ValueError("At least one adult household member is required") + if sum(1 for person in people if person.kind == "adult") > MAX_ADULTS: + raise ValueError(f"At most {MAX_ADULTS} adult household members are supported") + if sum(1 for person in people if person.kind == "child") > MAX_DEPENDENTS: + raise ValueError( + f"At most {MAX_DEPENDENTS} dependent household members are supported" + ) if filing_status not in FILING_STATUS_CODES: raise ValueError(f"Unsupported filing_status: {filing_status}") if ( @@ -336,6 +420,9 @@ def _validate_input(payload: HouseholdInput) -> None: raise ValueError(f"Unsupported household member kind: {person.kind}") if person.age < 0 or person.age > 120: raise ValueError(f"Invalid age: {person.age}") + for field_name in ("earned_income", "ssi_amount", "ssdi_amount"): + if getattr(person, field_name) < 0: + raise ValueError(f"{field_name} must be non-negative") def _base_person_data( @@ -349,6 +436,14 @@ def _base_person_data( "has_esi": {year: False}, "offered_aca_disqualifying_esi": {year: False}, "is_pregnant": {year: bool(person.get("is_pregnant", False))}, + "is_disabled": {year: bool(person.get("is_disabled", False))}, + "is_blind": {year: bool(person.get("is_blind", False))}, + "is_full_time_student": { + year: bool(person.get("is_full_time_student", False)) + }, + "is_incapable_of_self_care": { + year: bool(person.get("is_incapable_of_self_care", False)) + }, "under_60_days_postpartum": {year: False}, "immigration_status_str": {year: "CITIZEN"}, "is_ccdf_reason_for_care_eligible": {year: True}, @@ -369,37 +464,106 @@ def _base_household_groups( ) -> dict[str, Any]: year = payload.year earned_income = float(payload.earned_income) - monthly_earned = earned_income / 12 - rent_annual = float(getattr(payload, "rent_annual", 0.0) or 0.0) + rent_annual = _nonnegative(payload.rent_annual) + utility_expense_annual = _nonnegative(payload.utility_expense_annual) + health_insurance_premium_annual = _nonnegative( + payload.health_insurance_premium_annual + ) people: dict[str, dict[str, Any]] = {} member_ids = [person["id"] for person in members] + def set_earned_income(person_data: dict[str, Any], amount: float) -> None: + person_data["employment_income"] = {year: amount} + monthly_earned = amount / 12 + person_data["tanf_gross_earned_income"] = { + f"{year}-{month:02d}": monthly_earned for month in range(1, 13) + } + + state_specific_earned_income = { + "DC": "dc_tanf_gross_earned_income", + "IL": "il_tanf_gross_earned_income", + "MT": "mt_tanf_gross_earned_income_person", + "SC": "sc_tanf_gross_earned_income", + "TX": "tx_tanf_gross_earned_income", + } + variable = state_specific_earned_income.get(payload.state) + if variable: + person_data[variable] = { + f"{year}-{month:02d}": monthly_earned for month in range(1, 13) + } + for index, person in enumerate(members): person_data = _base_person_data(person, year=year) if index == 0 and rent_annual > 0: - person_data["rent"] = {year: rent_annual} + person_data["pre_subsidy_rent"] = {year: rent_annual} - if include_income_overrides and index == 0 and earned_income > 0: - person_data["employment_income"] = {year: earned_income} - person_data["tanf_gross_earned_income"] = { - f"{year}-{month:02d}": monthly_earned for month in range(1, 13) + if payload.has_employer_health_insurance: + person_data["has_esi"] = {year: True} + person_data["offered_aca_disqualifying_esi"] = {year: True} + + if index == 0 and health_insurance_premium_annual > 0: + person_data["health_insurance_premiums"] = { + year: health_insurance_premium_annual } - state_specific_earned_income = { - "DC": "dc_tanf_gross_earned_income", - "IL": "il_tanf_gross_earned_income", - "MT": "mt_tanf_gross_earned_income_person", - "SC": "sc_tanf_gross_earned_income", - "TX": "tx_tanf_gross_earned_income", + person_data["takes_up_medicaid_if_eligible"] = { + year: _program_included(payload, "medicaid") + } + person_data["takes_up_chip_if_eligible"] = { + year: _program_included(payload, "chip") + } + person_data["takes_up_ssi_if_eligible"] = { + year: _program_included(payload, "ssi") + } + person_data["takes_up_head_start_if_eligible"] = { + year: _program_included(payload, "head_start") + } + person_data["takes_up_early_head_start_if_eligible"] = { + year: _program_included(payload, "early_head_start") + } + person_data["is_enrolled_in_ccdf"] = { + year: _program_included(payload, "child_care_subsidies") + } + person_data["is_enrolled_in_head_start"] = { + year: _program_included(payload, "head_start") + } + person_data["receives_wic"] = { + f"{year}-{month:02d}": _program_included(payload, "wic") + for month in range(1, 13) + } + + if person["ssi_amount"] > 0 and _program_included(payload, "ssi"): + person_data["ssi"] = {year: person["ssi_amount"]} + if person["ssdi_amount"] > 0 and _program_included(payload, "ssdi"): + person_data["social_security_disability"] = { + year: person["ssdi_amount"] } - variable = state_specific_earned_income.get(payload.state) - if variable: - person_data[variable] = { - f"{year}-{month:02d}": monthly_earned for month in range(1, 13) - } + + fixed_earned_income = person["earned_income"] if index != 0 else 0.0 + if include_income_overrides and index == 0: + set_earned_income(person_data, earned_income) elif not include_income_overrides and index == 0: person_data["employment_income"] = {year: None} + elif fixed_earned_income > 0: + set_earned_income(person_data, fixed_earned_income) + + if index == 0: + extra_income_inputs = { + "self_employment_income": payload.self_employment_income_annual, + "child_support_received": payload.child_support_annual, + "taxable_interest_income": payload.taxable_interest_income_annual, + "dividend_income": payload.dividend_income_annual, + "rental_income": payload.rental_income_annual, + "unemployment_compensation": payload.unemployment_compensation_annual, + "pension_income": payload.pension_income_annual, + "social_security": payload.social_security_annual, + "miscellaneous_income": payload.miscellaneous_income_annual, + } + for variable, amount in extra_income_inputs.items(): + annual_amount = _nonnegative(amount) + if annual_amount > 0: + person_data[variable] = {year: annual_amount} people[person["id"]] = person_data @@ -415,18 +579,57 @@ def _base_household_groups( tax_unit = { "members": member_ids, "filing_status": {year: _effective_filing_status(payload)}, + "takes_up_aca_if_eligible": { + year: _program_included(payload, "aca_ptc") + }, + "takes_up_eitc": { + year: _program_included(payload, "federal_refundable_credits") + }, } if include_income_overrides: - tax_unit["aca_magi"] = {year: earned_income} - tax_unit["medicaid_magi"] = {year: earned_income} + estimated_magi = ( + earned_income + + sum(person["earned_income"] for person in members[1:]) + + _nonnegative(payload.self_employment_income_annual) + + _nonnegative(payload.taxable_interest_income_annual) + + _nonnegative(payload.dividend_income_annual) + + _nonnegative(payload.rental_income_annual) + + _nonnegative(payload.unemployment_compensation_annual) + + _nonnegative(payload.pension_income_annual) + + _nonnegative(payload.miscellaneous_income_annual) + ) + tax_unit["aca_magi"] = {year: estimated_magi} + tax_unit["medicaid_magi"] = {year: estimated_magi} - childcare_expenses = float(getattr(payload, "childcare_expenses", 0.0) or 0.0) + childcare_expenses = _nonnegative(payload.childcare_expenses) spm_unit: dict[str, Any] = { "members": member_ids, "meets_ccdf_activity_test": {year: True}, + "takes_up_snap_if_eligible": { + year: _program_included(payload, "snap") + }, + "takes_up_tanf_if_eligible": { + year: _program_included(payload, "tanf") + }, + "receives_housing_assistance": { + year: _program_included(payload, "housing_assistance") + }, } if childcare_expenses > 0: spm_unit["childcare_expenses"] = {year: childcare_expenses} + spm_unit["spm_unit_pre_subsidy_childcare_expenses"] = { + year: childcare_expenses + } + if utility_expense_annual > 0: + spm_unit["utility_expense"] = {year: utility_expense_annual} + household["hud_utility_allowance"] = {year: utility_expense_annual} + if payload.liquid_assets > 0: + spm_unit["snap_assets"] = {year: _nonnegative(payload.liquid_assets)} + + if _program_included(payload, "tanf"): + spm_unit["is_tanf_enrolled"] = { + f"{year}-{month:02d}": True for month in range(1, 13) + } situation: dict[str, Any] = { "people": people, @@ -703,6 +906,36 @@ def _simulate_core(payload: HouseholdInput) -> dict[str, Any]: year, map_to="household", ) + head_start = _calculate_variable( + simulation, + "head_start", + year, + map_to="household", + ) + early_head_start = _calculate_variable( + simulation, + "early_head_start", + year, + map_to="household", + ) + housing_assistance = _calculate_variable( + simulation, + "housing_assistance", + year, + map_to="spm_unit", + ) + ssi = _calculate_variable( + simulation, + "ssi", + year, + map_to="household", + ) + ssdi = _calculate_variable( + simulation, + "social_security_disability", + year, + map_to="household", + ) medicaid_value = _calculate_variable( simulation, "medicaid", @@ -805,18 +1038,47 @@ def _simulate_core(payload: HouseholdInput) -> dict[str, Any]: access[key] += 1 programs = { - "snap": snap, - "tanf": tanf, - "wic": wic, - "free_school_meals": free_school_meals, - "child_care_subsidies": child_care_subsidies, - "medicaid": medicaid_value, - "chip": chip_value, - "aca_ptc": aca_ptc, - "federal_refundable_credits": federal_refundable_credits, - "state_refundable_credits": state_refundable_credits, + "snap": _filtered_program_value(payload, "snap", snap), + "tanf": _filtered_program_value(payload, "tanf", tanf), + "wic": _filtered_program_value(payload, "wic", wic), + "free_school_meals": _filtered_program_value( + payload, "free_school_meals", free_school_meals + ), + "head_start": _filtered_program_value(payload, "head_start", head_start), + "early_head_start": _filtered_program_value( + payload, "early_head_start", early_head_start + ), + "child_care_subsidies": _filtered_program_value( + payload, "child_care_subsidies", child_care_subsidies + ), + "housing_assistance": _filtered_program_value( + payload, "housing_assistance", housing_assistance + ), + "ssi": _filtered_program_value(payload, "ssi", ssi), + "ssdi": _filtered_program_value(payload, "ssdi", ssdi), + "medicaid": _filtered_program_value(payload, "medicaid", medicaid_value), + "chip": _filtered_program_value(payload, "chip", chip_value), + "aca_ptc": _filtered_program_value(payload, "aca_ptc", aca_ptc), + "federal_refundable_credits": _filtered_program_value( + payload, "federal_refundable_credits", federal_refundable_credits + ), + "state_refundable_credits": _filtered_program_value( + payload, "state_refundable_credits", state_refundable_credits + ), } household_costs = { + "rent": _nonnegative(payload.rent_annual), + "utilities": _nonnegative(payload.utility_expense_annual), + "childcare": _nonnegative(payload.childcare_expenses), + "food": _nonnegative(payload.food_expense_annual), + "transportation": _nonnegative(payload.transportation_expense_annual), + "health_insurance_premiums": _nonnegative( + payload.health_insurance_premium_annual + ), + "technology": _nonnegative(payload.technology_expense_annual), + "debt_payments": _nonnegative(payload.debt_payment_annual), + "education_training": _nonnegative(payload.education_expense_annual), + "other_expenses": _nonnegative(payload.other_expense_annual), "chip_premium": chip_premium, } core_support = sum(programs.values()) @@ -836,9 +1098,47 @@ def _simulate_core(payload: HouseholdInput) -> dict[str, Any]: "kind": person["kind"], "age": person["age"], "is_pregnant": person["is_pregnant"], + "is_disabled": person["is_disabled"], + "is_blind": person["is_blind"], + "is_full_time_student": person["is_full_time_student"], + "is_incapable_of_self_care": person[ + "is_incapable_of_self_care" + ], + "earned_income": person["earned_income"], + "ssi_amount": person["ssi_amount"], + "ssdi_amount": person["ssdi_amount"], } for person in members ], + "childcare_expenses": payload.childcare_expenses, + "rent_annual": payload.rent_annual, + "utility_expense_annual": payload.utility_expense_annual, + "food_expense_annual": payload.food_expense_annual, + "transportation_expense_annual": payload.transportation_expense_annual, + "health_insurance_premium_annual": ( + payload.health_insurance_premium_annual + ), + "technology_expense_annual": payload.technology_expense_annual, + "debt_payment_annual": payload.debt_payment_annual, + "education_expense_annual": payload.education_expense_annual, + "other_expense_annual": payload.other_expense_annual, + "self_employment_income_annual": payload.self_employment_income_annual, + "child_support_annual": payload.child_support_annual, + "taxable_interest_income_annual": ( + payload.taxable_interest_income_annual + ), + "dividend_income_annual": payload.dividend_income_annual, + "rental_income_annual": payload.rental_income_annual, + "unemployment_compensation_annual": ( + payload.unemployment_compensation_annual + ), + "pension_income_annual": payload.pension_income_annual, + "social_security_annual": payload.social_security_annual, + "miscellaneous_income_annual": payload.miscellaneous_income_annual, + "liquid_assets": payload.liquid_assets, + "has_employer_health_insurance": payload.has_employer_health_insurance, + "programs_mode": payload.programs_mode, + "selected_programs": list(payload.selected_programs), }, "template": { "id": descriptor["id"], @@ -887,15 +1187,7 @@ def _attach_cliff_metrics( *, delta: int = DEFAULT_CLIFF_DELTA, ) -> dict[str, Any]: - bumped_payload = HouseholdInput( - state=payload.state, - earned_income=payload.earned_income + delta, - year=payload.year, - county=payload.county, - people=payload.people, - household_type=payload.household_type, - filing_status=payload.filing_status, - ) + bumped_payload = replace(payload, earned_income=payload.earned_income + delta) bumped_result = _simulate_core(bumped_payload) change = ( bumped_result["totals"]["net_resources"] @@ -984,7 +1276,8 @@ def _build_cliff_drivers( for key, label in PROGRAM_LABEL_BY_KEY.items(): annual_change = round( - result["programs"][key] - previous_result["programs"][key], + result["programs"].get(key, 0.0) + - previous_result["programs"].get(key, 0.0), 2, ) if annual_change < 0: @@ -1002,7 +1295,8 @@ def _build_cliff_drivers( for key, label in HOUSEHOLD_COST_LABEL_BY_KEY.items(): annual_change = round( - result["household_costs"][key] - previous_result["household_costs"][key], + result["household_costs"].get(key, 0.0) + - previous_result["household_costs"].get(key, 0.0), 2, ) if annual_change > 0: @@ -1173,6 +1467,51 @@ def calculate_income_series( ), point_count, ) + head_start_values = _normalize_series_values( + _calculate_variable_array( + simulation, + "head_start", + payload.year, + map_to="household", + ), + point_count, + ) + early_head_start_values = _normalize_series_values( + _calculate_variable_array( + simulation, + "early_head_start", + payload.year, + map_to="household", + ), + point_count, + ) + housing_assistance_values = _normalize_series_values( + _calculate_variable_array( + simulation, + "housing_assistance", + payload.year, + map_to="spm_unit", + ), + point_count, + ) + ssi_values = _normalize_series_values( + _calculate_variable_array( + simulation, + "ssi", + payload.year, + map_to="household", + ), + point_count, + ) + ssdi_values = _normalize_series_values( + _calculate_variable_array( + simulation, + "social_security_disability", + payload.year, + map_to="household", + ), + point_count, + ) medicaid_values = _normalize_series_values( _calculate_variable_array( simulation, @@ -1235,27 +1574,102 @@ def calculate_income_series( step_monthly = _monthly_amount(effective_step) truncated = False truncation_reason = None + fixed_household_costs = { + "rent": _nonnegative(payload.rent_annual), + "utilities": _nonnegative(payload.utility_expense_annual), + "childcare": _nonnegative(payload.childcare_expenses), + "food": _nonnegative(payload.food_expense_annual), + "transportation": _nonnegative(payload.transportation_expense_annual), + "health_insurance_premiums": _nonnegative( + payload.health_insurance_premium_annual + ), + "technology": _nonnegative(payload.technology_expense_annual), + "debt_payments": _nonnegative(payload.debt_payment_annual), + "education_training": _nonnegative(payload.education_expense_annual), + "other_expenses": _nonnegative(payload.other_expense_annual), + } for index, earned_income in enumerate(earned_incomes): programs = { - "snap": round(snap_values[index], 2), - "tanf": round(tanf_values[index], 2), - "wic": round(wic_values[index], 2), - "free_school_meals": round(free_school_meal_values[index], 2), - "child_care_subsidies": round(child_care_subsidy_values[index], 2), - "medicaid": round(medicaid_values[index], 2), - "chip": round(chip_values[index], 2), - "aca_ptc": round(aca_ptc_values[index], 2), + "snap": round( + _filtered_program_value(payload, "snap", snap_values[index]), 2 + ), + "tanf": round( + _filtered_program_value(payload, "tanf", tanf_values[index]), 2 + ), + "wic": round( + _filtered_program_value(payload, "wic", wic_values[index]), 2 + ), + "free_school_meals": round( + _filtered_program_value( + payload, "free_school_meals", free_school_meal_values[index] + ), + 2, + ), + "head_start": round( + _filtered_program_value( + payload, "head_start", head_start_values[index] + ), + 2, + ), + "early_head_start": round( + _filtered_program_value( + payload, "early_head_start", early_head_start_values[index] + ), + 2, + ), + "child_care_subsidies": round( + _filtered_program_value( + payload, + "child_care_subsidies", + child_care_subsidy_values[index], + ), + 2, + ), + "housing_assistance": round( + _filtered_program_value( + payload, + "housing_assistance", + housing_assistance_values[index], + ), + 2, + ), + "ssi": round( + _filtered_program_value(payload, "ssi", ssi_values[index]), 2 + ), + "ssdi": round( + _filtered_program_value(payload, "ssdi", ssdi_values[index]), 2 + ), + "medicaid": round( + _filtered_program_value(payload, "medicaid", medicaid_values[index]), + 2, + ), + "chip": round( + _filtered_program_value(payload, "chip", chip_values[index]), 2 + ), + "aca_ptc": round( + _filtered_program_value(payload, "aca_ptc", aca_ptc_values[index]), + 2, + ), "federal_refundable_credits": round( - federal_refundable_credit_values[index], + _filtered_program_value( + payload, + "federal_refundable_credits", + federal_refundable_credit_values[index], + ), 2, ), "state_refundable_credits": round( - state_refundable_credit_values[index], + _filtered_program_value( + payload, + "state_refundable_credits", + state_refundable_credit_values[index], + ), 2, ), } household_costs = { + **fixed_household_costs, "chip_premium": round(chip_premium_values[index], 2), } market_income = round(market_income_values[index], 2) @@ -1316,6 +1730,11 @@ def calculate_income_series( "aca_ptc": programs["aca_ptc"], "snap": programs["snap"], "free_school_meals": programs["free_school_meals"], + "head_start": programs["head_start"], + "early_head_start": programs["early_head_start"], + "housing_assistance": programs["housing_assistance"], + "ssi": programs["ssi"], + "ssdi": programs["ssdi"], "federal_refundable_credits": programs["federal_refundable_credits"], "state_refundable_credits": programs["state_refundable_credits"], "federal_taxes_before_refundable_credits": federal_taxes_before_refundable_credits, @@ -1323,6 +1742,7 @@ def calculate_income_series( "tanf": programs["tanf"], "wic": programs["wic"], "child_care_subsidies": programs["child_care_subsidies"], + "household_costs": household_costs, "has_previous_point": has_previous_point, "cliff_drop_annual": round(cliff_drop, 2), "is_cliff": cliff_drop > 0, @@ -1346,12 +1766,10 @@ def calculate_income_series( def calculate_household_types(payload: HouseholdInput) -> list[dict[str, Any]]: results = [] for household_type in HOUSEHOLD_TYPE_BY_ID: - scenario = HouseholdInput( - state=payload.state, - earned_income=payload.earned_income, - year=payload.year, - county=payload.county, + scenario = replace( + payload, household_type=household_type, + people=(), filing_status=None, ) result = _simulate_core(scenario) @@ -1386,6 +1804,9 @@ def calculate_household_types(payload: HouseholdInput) -> list[dict[str, Any]]: def household_input_from_dict(data: dict[str, Any]) -> HouseholdInput: + def numeric(field: str) -> float: + return float(data.get(field, 0) or 0) + people = tuple( HouseholdMemberInput( age=int(person["age"]), @@ -1402,19 +1823,62 @@ def household_input_from_dict(data: dict[str, Any]) -> HouseholdInput: ) ), is_pregnant=bool(person.get("is_pregnant", False)), + is_disabled=bool(person.get("is_disabled", False)), + is_blind=bool(person.get("is_blind", False)), + is_full_time_student=bool(person.get("is_full_time_student", False)), + is_incapable_of_self_care=bool( + person.get("is_incapable_of_self_care", False) + ), + earned_income=float(person.get("earned_income", 0) or 0), + ssi_amount=float(person.get("ssi_amount", 0) or 0), + ssdi_amount=float(person.get("ssdi_amount", 0) or 0), ) for person in data.get("people", []) ) + selected_programs = tuple( + str(key) + for key in data.get("selected_programs", []) + if str(key) in PUBLIC_ASSISTANCE_PROGRAM_KEYS + ) return HouseholdInput( state=data["state"], - earned_income=float(data.get("earned_income", 0)), + earned_income=numeric("earned_income"), year=int(data.get("year", DEFAULT_YEAR)), county=data.get("county"), people=people, household_type=data.get("household_type"), filing_status=data.get("filing_status"), - childcare_expenses=float(data.get("childcare_expenses", 0) or 0), - rent_annual=float(data.get("rent_annual", 0) or 0), + childcare_expenses=numeric("childcare_expenses"), + rent_annual=numeric("rent_annual"), + utility_expense_annual=numeric("utility_expense_annual"), + food_expense_annual=numeric("food_expense_annual"), + transportation_expense_annual=numeric("transportation_expense_annual"), + health_insurance_premium_annual=numeric( + "health_insurance_premium_annual" + ), + technology_expense_annual=numeric("technology_expense_annual"), + debt_payment_annual=numeric("debt_payment_annual"), + education_expense_annual=numeric("education_expense_annual"), + other_expense_annual=numeric("other_expense_annual"), + self_employment_income_annual=numeric("self_employment_income_annual"), + child_support_annual=numeric("child_support_annual"), + taxable_interest_income_annual=numeric( + "taxable_interest_income_annual" + ), + dividend_income_annual=numeric("dividend_income_annual"), + rental_income_annual=numeric("rental_income_annual"), + unemployment_compensation_annual=numeric( + "unemployment_compensation_annual" + ), + pension_income_annual=numeric("pension_income_annual"), + social_security_annual=numeric("social_security_annual"), + miscellaneous_income_annual=numeric("miscellaneous_income_annual"), + liquid_assets=numeric("liquid_assets"), + has_employer_health_insurance=bool( + data.get("has_employer_health_insurance", False) + ), + programs_mode=str(data.get("programs_mode", "all") or "all"), + selected_programs=selected_programs, ) diff --git a/cliff_watch/config.py b/cliff_watch/config.py index 7a4ffeb..5b7cbcd 100644 --- a/cliff_watch/config.py +++ b/cliff_watch/config.py @@ -9,6 +9,8 @@ DEFAULT_SERIES_TARGET_POINTS = 201 DEFAULT_SERIES_STEP_INCREMENT = 250 DEFAULT_FILING_STATUS = "HEAD_OF_HOUSEHOLD" +MAX_ADULTS = 6 +MAX_DEPENDENTS = 6 FILING_STATUS_OPTIONS = [ {"code": "SINGLE", "label": "Single"}, { @@ -231,12 +233,42 @@ "short_label": "Meals", "description": "Modeled value of free school breakfast and lunch.", }, + { + "key": "head_start", + "label": "Head Start", + "short_label": "Head Start", + "description": "Modeled value of Head Start services for eligible children.", + }, + { + "key": "early_head_start", + "label": "Early Head Start", + "short_label": "Early HS", + "description": "Modeled value of Early Head Start services for eligible infants, toddlers, and pregnant people.", + }, { "key": "child_care_subsidies", "label": "Child care subsidies", "short_label": "Child care", "description": "State CCDF child care subsidy net of family copay (modeled in CA, CO, DE, MA, ME, NE, NH, PA, RI, VT).", }, + { + "key": "housing_assistance", + "label": "Housing assistance", + "short_label": "Housing", + "description": "Modeled value of HUD housing assistance when the household is already receiving housing assistance.", + }, + { + "key": "ssi", + "label": "SSI", + "short_label": "SSI", + "description": "Supplemental Security Income for eligible disabled, blind, or aged people.", + }, + { + "key": "ssdi", + "label": "SSDI", + "short_label": "SSDI", + "description": "Reported Social Security Disability Insurance income.", + }, { "key": "federal_refundable_credits", "label": "Federal refundable tax credits", @@ -270,6 +302,66 @@ ] HOUSEHOLD_COST_DEFINITIONS = [ + { + "key": "rent", + "label": "Rent or mortgage", + "short_label": "Housing", + "description": "Annual rent or mortgage entered by the household. Subtracted from net resources.", + }, + { + "key": "utilities", + "label": "Utilities", + "short_label": "Utilities", + "description": "Annual utility costs entered by the household. Subtracted from net resources.", + }, + { + "key": "childcare", + "label": "Child care expense", + "short_label": "Child care", + "description": "Annual out-of-pocket child care expense entered by the household. Subtracted from net resources.", + }, + { + "key": "food", + "label": "Food", + "short_label": "Food", + "description": "Annual food costs entered by the household. Subtracted from net resources.", + }, + { + "key": "transportation", + "label": "Transportation", + "short_label": "Transport", + "description": "Annual transportation costs entered by the household. Subtracted from net resources.", + }, + { + "key": "health_insurance_premiums", + "label": "Health insurance premiums", + "short_label": "Health premiums", + "description": "Annual out-of-pocket health insurance premiums entered by the household. Subtracted from net resources.", + }, + { + "key": "technology", + "label": "Phone and internet", + "short_label": "Tech", + "description": "Annual phone and internet costs entered by the household. Subtracted from net resources.", + }, + { + "key": "debt_payments", + "label": "Debt payments", + "short_label": "Debt", + "description": "Annual debt payments entered by the household. Subtracted from net resources.", + }, + { + "key": "education_training", + "label": "Education and training", + "short_label": "Training", + "description": "Annual education or training costs entered by the household. Subtracted from net resources.", + }, + { + "key": "other_expenses", + "label": "Other expenses", + "short_label": "Other", + "description": "Other annual budget costs entered by the household. Subtracted from net resources.", + }, { "key": "chip_premium", "label": "CHIP premium", @@ -277,3 +369,66 @@ "description": "Annual CHIP premium or enrollment fee paid by the household. Subtracted from net resources.", }, ] + +PUBLIC_ASSISTANCE_PROGRAM_OPTIONS = [ + { + "key": "snap", + "label": "Supplemental Nutrition Assistance Program (SNAP)", + }, + { + "key": "free_school_meals", + "label": "Free or reduced price school meals", + }, + { + "key": "wic", + "label": "Women, Infants, and Children Nutrition Program (WIC)", + }, + { + "key": "tanf", + "label": "Temporary Assistance for Needy Families (TANF)", + }, + { + "key": "child_care_subsidies", + "label": "Child Care Subsidy (CCDF)", + }, + { + "key": "head_start", + "label": "Head Start", + }, + { + "key": "early_head_start", + "label": "Early Head Start", + }, + { + "key": "housing_assistance", + "label": "Section 8 Housing Choice Voucher", + }, + { + "key": "medicaid", + "label": "Medicaid for adults", + }, + { + "key": "chip", + "label": "Medicaid for children / CHIP", + }, + { + "key": "aca_ptc", + "label": "Health Insurance Marketplace Subsidy", + }, + { + "key": "federal_refundable_credits", + "label": "Federal refundable tax credits", + }, + { + "key": "state_refundable_credits", + "label": "State refundable tax credits", + }, + { + "key": "ssi", + "label": "Supplemental Security Income (SSI)", + }, + { + "key": "ssdi", + "label": "Social Security Disability Insurance (SSDI)", + }, +] diff --git a/frontend/src/App.css b/frontend/src/App.css index dc847cc..ab132e6 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -142,6 +142,10 @@ body { grid-template-columns: 1fr; } +.form-grid--three { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + .form-group { margin-bottom: 0; } @@ -163,9 +167,41 @@ body { } .advanced-grid { + display: grid; + gap: 1rem; padding: 0 0.9rem 0.9rem; } +.advanced-section { + display: grid; + gap: 0.75rem; + padding-top: 0.9rem; + border-top: 1px solid var(--color-border); +} + +.advanced-section:first-child { + border-top: none; + padding-top: 0.2rem; +} + +.advanced-section-title { + font-size: 0.74rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-navy); +} + +.advanced-field-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.8rem; +} + +.advanced-field-grid--two { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + .form-group label { display: block; font-weight: 600; @@ -424,6 +460,17 @@ body { align-items: center; } +.person-card-fields { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.65rem; + align-items: end; +} + +.person-card-fields--dependent { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + .compact-field { display: grid; gap: 0.28rem; @@ -480,12 +527,102 @@ body { background: white; } +.member-checkbox-label--compact, +.member-checkbox-label--program, +.member-checkbox-label--standalone { + min-height: 38px; + padding: 0.48rem 0.6rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: white; + white-space: normal; + line-height: 1.25; +} + +.member-checkbox-label--standalone { + min-height: 48px; + width: 100%; + margin-top: 1.08rem; +} + .member-checkbox-label input[type='checkbox'] { width: 16px; height: 16px; accent-color: var(--color-teal-dark); } +.person-option-grid { + display: grid; + grid-template-columns: minmax(120px, 0.28fr) minmax(0, 1fr); + gap: 0.65rem; + align-items: start; +} + +.person-flag-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.5rem; +} + +.dependent-card-grid { + display: grid; + grid-template-columns: 1fr; + gap: 0.8rem; +} + +.dependent-card { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 0.9rem; + background: linear-gradient(180deg, #fbfdff 0%, #f8fafc 100%); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); +} + +.program-mode-toggle { + display: inline-flex; + width: fit-content; + padding: 0.2rem; + border: 1px solid var(--color-border); + border-radius: 999px; + background: var(--color-cream); +} + +.program-mode-button { + min-width: 5rem; + padding: 0.42rem 0.85rem; + border: none; + border-radius: 999px; + background: transparent; + color: var(--color-text-muted); + font-family: var(--font-sans); + font-size: 0.78rem; + font-weight: 700; + cursor: pointer; + transition: var(--transition); +} + +.program-mode-button:hover { + color: var(--color-navy); +} + +.program-mode-button.active { + background: var(--color-teal-dark); + color: white; + box-shadow: var(--shadow-sm); +} + +.program-checkbox-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.55rem; +} + +.checkbox-form-group { + align-self: stretch; +} + .dependent-chip-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); @@ -1711,6 +1848,9 @@ body { @media (max-width: 900px) { .form-grid, + .form-grid--three, + .advanced-field-grid, + .advanced-field-grid--two, .charts-grid { grid-template-columns: 1fr; } @@ -1723,6 +1863,17 @@ body { grid-template-columns: minmax(96px, 120px) 1fr; } + .person-card-fields, + .person-card-fields--dependent, + .person-option-grid, + .program-checkbox-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .person-flag-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .tab-content { padding: 1.5rem; } @@ -1794,6 +1945,25 @@ body { grid-template-columns: 1fr; } + .advanced-field-grid, + .advanced-field-grid--two, + .person-card-fields, + .person-card-fields--dependent, + .person-option-grid, + .person-flag-grid, + .program-checkbox-grid { + grid-template-columns: 1fr; + } + + .program-mode-toggle { + width: 100%; + } + + .program-mode-button { + flex: 1; + min-width: 0; + } + .member-section-header, .member-subsection-header { flex-direction: column; diff --git a/frontend/src/components/BenefitChart.jsx b/frontend/src/components/BenefitChart.jsx index 4be4cc8..bf47a8b 100644 --- a/frontend/src/components/BenefitChart.jsx +++ b/frontend/src/components/BenefitChart.jsx @@ -128,6 +128,24 @@ const PROGRAM_DETAIL_SUPPORT_SERIES = [ fill: '#F0D29E', defaultVisible: true, }, + { + key: 'head_start_annual', + label: 'Head Start', + type: 'area', + family: 'support', + stroke: '#0E7490', + fill: '#A5F3FC', + defaultVisible: true, + }, + { + key: 'early_head_start_annual', + label: 'Early Head Start', + type: 'area', + family: 'support', + stroke: '#155E75', + fill: '#BAE6FD', + defaultVisible: true, + }, { key: 'child_care_subsidies_annual', label: 'Child care subsidies', @@ -137,6 +155,33 @@ const PROGRAM_DETAIL_SUPPORT_SERIES = [ fill: '#DDD6FE', defaultVisible: true, }, + { + key: 'housing_assistance_annual', + label: 'Housing assistance', + type: 'area', + family: 'support', + stroke: '#047857', + fill: '#A7F3D0', + defaultVisible: true, + }, + { + key: 'ssi_annual', + label: 'SSI', + type: 'area', + family: 'support', + stroke: '#B45309', + fill: '#FDE68A', + defaultVisible: true, + }, + { + key: 'ssdi_annual', + label: 'SSDI', + type: 'area', + family: 'support', + stroke: '#92400E', + fill: '#FED7AA', + defaultVisible: true, + }, { key: 'medicaid_annual', label: 'Medicaid', @@ -376,7 +421,12 @@ function BenefitChart({ state_refundable_credits_annual: stateRefundableCreditsAnnual, tanf_annual: Number(point.tanf || 0), free_school_meals_annual: Number(point.free_school_meals || 0), + head_start_annual: Number(point.head_start || 0), + early_head_start_annual: Number(point.early_head_start || 0), child_care_subsidies_annual: Number(point.child_care_subsidies || 0), + housing_assistance_annual: Number(point.housing_assistance || 0), + ssi_annual: Number(point.ssi || 0), + ssdi_annual: Number(point.ssdi || 0), wic_annual: Number(point.wic || 0), net_change_annual_display: Number(point.net_change_annual || 0), cliff_drop_annual: Number(point.cliff_drop_annual || 0), diff --git a/frontend/src/components/InputPanel.jsx b/frontend/src/components/InputPanel.jsx index 4a0077c..b46b497 100644 --- a/frontend/src/components/InputPanel.jsx +++ b/frontend/src/components/InputPanel.jsx @@ -1,5 +1,56 @@ import { useMemo } from 'react' +const PROGRAM_MODES = [ + { key: 'all', label: 'All' }, + { key: 'none', label: 'None' }, + { key: 'custom', label: 'Custom' }, +] + +const PERSON_FLAGS = [ + { key: 'is_disabled', label: 'Disabled' }, + { key: 'is_blind', label: 'Blind' }, + { key: 'is_full_time_student', label: 'Student' }, + { key: 'is_incapable_of_self_care', label: 'Needs care' }, +] + +const OTHER_INCOME_FIELDS = [ + { key: 'self_employment_income_annual', label: 'Self-employment' }, + { key: 'child_support_annual', label: 'Child support' }, + { key: 'taxable_interest_income_annual', label: 'Interest' }, + { key: 'dividend_income_annual', label: 'Dividends' }, + { key: 'rental_income_annual', label: 'Rental income' }, + { key: 'unemployment_compensation_annual', label: 'Unemployment' }, + { key: 'pension_income_annual', label: 'Pension' }, + { key: 'social_security_annual', label: 'Social Security' }, + { key: 'miscellaneous_income_annual', label: 'Other income' }, +] + +const EXPENSE_FIELDS = [ + { key: 'childcare_expenses', label: 'Child care' }, + { key: 'rent_annual', label: 'Rent or mortgage' }, + { key: 'utility_expense_annual', label: 'Utilities' }, + { key: 'food_expense_annual', label: 'Food' }, + { key: 'transportation_expense_annual', label: 'Transportation' }, + { key: 'health_insurance_premium_annual', label: 'Health premiums' }, + { key: 'technology_expense_annual', label: 'Phone and internet' }, + { key: 'debt_payment_annual', label: 'Debt payments' }, + { key: 'education_expense_annual', label: 'Education and training' }, + { key: 'other_expense_annual', label: 'Other expenses' }, +] + +const newPerson = (kind) => ({ + kind, + age: kind === 'adult' ? 30 : 6, + is_pregnant: false, + is_disabled: false, + is_blind: false, + is_full_time_student: false, + is_incapable_of_self_care: false, + earned_income: 0, + ssi_amount: 0, + ssdi_amount: 0, +}) + function InfoTooltip({ text }) { return ( @@ -9,27 +60,93 @@ function InfoTooltip({ text }) { ) } +function CurrencyField({ + id, + label, + value, + onChange, + compact = false, + step = 500, + tooltip, +}) { + const input = ( + onChange(Number(event.target.value) || 0)} + /> + ) + + if (compact) { + return ( + + ) + } + + return ( +
+ + {input} +
+ ) +} + +function PersonFlagGrid({ person, updatePerson }) { + return ( +
+ {PERSON_FLAGS.map((flag) => ( + + ))} +
+ ) +} + function InputPanel({ metadata, inputs, loading, onCalculate, onChange, onReset }) { const people = inputs?.people || [] const adultCount = people.filter((person) => person.kind === 'adult').length + const dependentCount = people.filter((person) => person.kind === 'child').length + const maxAdults = Math.max(1, Number(metadata?.defaults?.max_adults) || 6) + const maxDependents = Math.max(0, Number(metadata?.defaults?.max_dependents) || 6) + const programOptions = metadata?.public_assistance_programs || metadata?.programs || [] + const selectedPrograms = new Set(inputs?.selected_programs || programOptions.map((program) => program.key)) const rowMeta = useMemo(() => { - let adultCount = 0 - let dependentCount = 0 + let adultOrdinal = 0 + let dependentOrdinal = 0 return people.map((person) => { if (person.kind === 'child') { - dependentCount += 1 + dependentOrdinal += 1 return { - label: `Dependent ${dependentCount}`, + ordinal: dependentOrdinal, + label: `Dependent ${dependentOrdinal}`, } } - adultCount += 1 + adultOrdinal += 1 return { - label: adultCount === 2 - ? 'Adult 2 (spouse)' - : `Adult ${adultCount}`, + ordinal: adultOrdinal, + label: adultOrdinal === 1 + ? 'Adult 1' + : adultOrdinal === 2 + ? 'Adult 2' + : `Adult ${adultOrdinal}`, } }) }, [people]) @@ -46,30 +163,20 @@ function InputPanel({ metadata, inputs, loading, onCalculate, onChange, onReset const addPerson = (kind) => { if (kind === 'adult') { - if (adultCount >= 2) { - return - } + if (adultCount >= maxAdults) return const lastAdultIndex = people.reduce((lastIndex, person, index) => ( person.kind === 'adult' ? index : lastIndex ), -1) - const nextAdult = { kind, age: 30, is_pregnant: false } const nextPeople = [...people] - nextPeople.splice(lastAdultIndex + 1, 0, nextAdult) - - onChange({ - people: nextPeople, - }) + nextPeople.splice(lastAdultIndex + 1, 0, newPerson('adult')) + onChange({ people: nextPeople }) return } - onChange({ - people: [ - ...people, - { kind, age: kind === 'adult' ? 30 : 6, is_pregnant: false }, - ], - }) + if (dependentCount >= maxDependents) return + onChange({ people: [...people, newPerson('child')] }) } const removePerson = (index) => { @@ -78,12 +185,22 @@ function InputPanel({ metadata, inputs, loading, onCalculate, onChange, onReset }) } + const toggleProgram = (key) => { + const next = new Set(selectedPrograms) + if (next.has(key)) { + next.delete(key) + } else { + next.add(key) + } + onChange({ selected_programs: programOptions.map((program) => program.key).filter((programKey) => next.has(programKey)) }) + } + const adultMembers = people - .map((person, index) => ({ person, index, label: rowMeta[index]?.label || `Adult ${index + 1}` })) + .map((person, index) => ({ person, index, meta: rowMeta[index] })) .filter(({ person }) => person.kind === 'adult') const dependentMembers = people - .map((person, index) => ({ person, index, label: rowMeta[index]?.label || `Dependent ${index + 1}` })) + .map((person, index) => ({ person, index, meta: rowMeta[index] })) .filter(({ person }) => person.kind === 'child') if (!metadata || !inputs) { @@ -104,7 +221,7 @@ function InputPanel({ metadata, inputs, loading, onCalculate, onChange, onReset onCalculate() }} > -
+
onChange({ county: event.target.value })} + placeholder="Optional" + /> +
+ +
+ + +
@@ -135,45 +278,43 @@ function InputPanel({ metadata, inputs, loading, onCalculate, onChange, onReset
Adults
- {adultCount === 1 - ? '1 adult in the tax unit.' - : 'Up to two adults in the tax unit.'} + {adultCount} of {maxAdults}
- {adultMembers.map(({ person, index, label }) => ( + {adultMembers.map(({ person, index, meta }) => (
-
{label}
+
{meta?.label}
-
+
-
+ +
+ + updatePerson(index, partial)} + />
))} @@ -201,48 +374,76 @@ function InputPanel({ metadata, inputs, loading, onCalculate, onChange, onReset
Dependents
- {dependentMembers.length === 0 - ? 'No dependents added.' - : `${dependentMembers.length} ${dependentMembers.length === 1 ? 'dependent' : 'dependents'}`} + {dependentCount} of {maxDependents}
-
{dependentMembers.length > 0 ? ( -
- {dependentMembers.map(({ person, index, label }) => ( -
-
@@ -250,50 +451,99 @@ function InputPanel({ metadata, inputs, loading, onCalculate, onChange, onReset
- Advanced options + Advanced inputs
-
- - onChange({ chart_max_earned_income: Number(event.target.value) || 100000 })} - /> -
-
- - onChange({ childcare_expenses: Number(event.target.value) || 0 })} - /> -
-
- - onChange({ rent_annual: Number(event.target.value) || 0 })} - /> -
+
+

Program participation

+
+ {PROGRAM_MODES.map((mode) => ( + + ))} +
+ + {inputs.programs_mode === 'custom' ? ( +
+ {programOptions.map((program) => ( + + ))} +
+ ) : null} +
+ +
+

Scenario

+
+ onChange({ chart_max_earned_income: value || 100000 })} + tooltip="Optional upper bound for the wage chart." + /> +
+ +
+
+
+ +
+

Other income and assets

+
+ {OTHER_INCOME_FIELDS.map((field) => ( + onChange({ [field.key]: value })} + /> + ))} + onChange({ liquid_assets: value })} + tooltip="Liquid assets used by asset-tested programs such as SNAP where modeled." + /> +
+
+ +
+

Budget costs

+
+ {EXPENSE_FIELDS.map((field) => ( + onChange({ [field.key]: value })} + /> + ))} +
+
diff --git a/frontend/src/dataLookup.js b/frontend/src/dataLookup.js index 6195b45..0b6213f 100644 --- a/frontend/src/dataLookup.js +++ b/frontend/src/dataLookup.js @@ -48,23 +48,92 @@ export async function loadMetadata() { return response.json() } -const MAX_ADULTS = 2 -const normalizePeople = (people = []) => { +const MAX_ADULTS_FALLBACK = 6 +const MAX_DEPENDENTS_FALLBACK = 6 + +const VALID_FILING_STATUSES = new Set([ + 'SINGLE', + 'HEAD_OF_HOUSEHOLD', + 'JOINT', + 'SEPARATE', +]) + +const MARRIED_FILING_STATUSES = new Set(['JOINT', 'SEPARATE']) + +const COST_FIELDS = [ + 'childcare_expenses', + 'rent_annual', + 'utility_expense_annual', + 'food_expense_annual', + 'transportation_expense_annual', + 'health_insurance_premium_annual', + 'technology_expense_annual', + 'debt_payment_annual', + 'education_expense_annual', + 'other_expense_annual', +] + +const INCOME_AND_ASSET_FIELDS = [ + 'self_employment_income_annual', + 'child_support_annual', + 'taxable_interest_income_annual', + 'dividend_income_annual', + 'rental_income_annual', + 'unemployment_compensation_annual', + 'pension_income_annual', + 'social_security_annual', + 'miscellaneous_income_annual', + 'liquid_assets', +] + +const nonnegative = (value) => Math.max(0, Number(value) || 0) + +const getPublicAssistanceProgramKeys = (metadata) => { + const options = metadata?.public_assistance_programs + if (Array.isArray(options) && options.length) { + return options.map((program) => program.key) + } + return (metadata?.programs || []).map((program) => program.key) +} + +const normalizePeople = (people = [], metadata) => { let adultCount = 0 + let dependentCount = 0 + const maxAdults = Math.max( + 1, + Number(metadata?.defaults?.max_adults) || MAX_ADULTS_FALLBACK, + ) + const maxDependents = Math.max( + 0, + Number(metadata?.defaults?.max_dependents) || MAX_DEPENDENTS_FALLBACK, + ) - return people.map((person) => { + return people.flatMap((person) => { const requestedAdult = person?.kind !== 'child' - const kind = requestedAdult && adultCount < MAX_ADULTS ? 'adult' : 'child' + const kind = requestedAdult && adultCount < maxAdults ? 'adult' : 'child' + + if (kind === 'child' && dependentCount >= maxDependents) { + return [] + } if (kind === 'adult') { adultCount += 1 + } else { + dependentCount += 1 } - return { + return [{ kind, age: Math.max(0, Number(person?.age) || 0), is_pregnant: kind === 'adult' ? Boolean(person?.is_pregnant) : false, - } + is_disabled: Boolean(person?.is_disabled), + is_blind: Boolean(person?.is_blind), + is_full_time_student: Boolean(person?.is_full_time_student), + is_incapable_of_self_care: Boolean(person?.is_incapable_of_self_care), + earned_income: nonnegative(person?.earned_income), + ssi_amount: nonnegative(person?.ssi_amount), + ssdi_amount: nonnegative(person?.ssdi_amount), + }] }) } @@ -82,8 +151,16 @@ const deriveFilingStatus = (people = []) => { } export function reconcileInputs(inputs, metadata) { - const normalizedPeople = normalizePeople(inputs?.people || []) - const filing_status = deriveFilingStatus(normalizedPeople) + const normalizedPeople = normalizePeople(inputs?.people || [], metadata) + const requestedFilingStatus = inputs?.filing_status + const adultCount = normalizedPeople.filter((person) => person.kind === 'adult').length + const canUseRequestedFilingStatus = ( + VALID_FILING_STATUSES.has(requestedFilingStatus) + && (!MARRIED_FILING_STATUSES.has(requestedFilingStatus) || adultCount >= 2) + ) + const filing_status = canUseRequestedFilingStatus + ? requestedFilingStatus + : deriveFilingStatus(normalizedPeople) const defaultChartMax = Math.max( 10000, Number(metadata?.defaults?.chart_max_earned_income) @@ -91,31 +168,56 @@ export function reconcileInputs(inputs, metadata) { || 100000, ) - return { + const publicAssistanceProgramKeys = getPublicAssistanceProgramKeys(metadata) + const selectedProgramSet = new Set( + Array.isArray(inputs?.selected_programs) + ? inputs.selected_programs.filter((key) => publicAssistanceProgramKeys.includes(key)) + : publicAssistanceProgramKeys, + ) + const programsMode = ['all', 'none', 'custom'].includes(inputs?.programs_mode) + ? inputs.programs_mode + : metadata?.defaults?.programs_mode || 'all' + + const next = { ...inputs, state: inputs?.state || metadata?.defaults?.state || 'GA', + county: String(inputs?.county || '').trim(), people: normalizedPeople, filing_status, chart_max_earned_income: Math.max( 10000, Number(inputs?.chart_max_earned_income) || defaultChartMax, ), - childcare_expenses: Math.max(0, Number(inputs?.childcare_expenses) || 0), - rent_annual: Math.max(0, Number(inputs?.rent_annual) || 0), + programs_mode: programsMode, + selected_programs: publicAssistanceProgramKeys.filter((key) => selectedProgramSet.has(key)), + has_employer_health_insurance: Boolean(inputs?.has_employer_health_insurance), year: metadata?.year || 2026, } + + COST_FIELDS.forEach((field) => { + next[field] = nonnegative(inputs?.[field]) + }) + INCOME_AND_ASSET_FIELDS.forEach((field) => { + next[field] = nonnegative(inputs?.[field]) + }) + + return next } export function createInitialInputs(metadata) { return reconcileInputs({ state: metadata?.defaults?.state || 'GA', - people: normalizePeople(metadata?.defaults?.people || []), + county: '', + people: normalizePeople(metadata?.defaults?.people || [], metadata), chart_max_earned_income: metadata?.defaults?.chart_max_earned_income || metadata?.defaults?.series_max_earned_income || 100000, - childcare_expenses: 0, - rent_annual: 0, + programs_mode: metadata?.defaults?.programs_mode || 'all', + selected_programs: getPublicAssistanceProgramKeys(metadata), + has_employer_health_insurance: false, + ...Object.fromEntries(COST_FIELDS.map((field) => [field, 0])), + ...Object.fromEntries(INCOME_AND_ASSET_FIELDS.map((field) => [field, 0])), }, metadata) } @@ -123,11 +225,17 @@ export function normalizeInputs(inputs, metadata) { const reconciled = reconcileInputs(inputs, metadata) return { state: reconciled.state, + county: reconciled.county, people: reconciled.people, filing_status: reconciled.filing_status, chart_max_earned_income: reconciled.chart_max_earned_income, - childcare_expenses: reconciled.childcare_expenses, - rent_annual: reconciled.rent_annual, + programs_mode: reconciled.programs_mode, + selected_programs: reconciled.selected_programs, + has_employer_health_insurance: reconciled.has_employer_health_insurance, + ...Object.fromEntries(COST_FIELDS.map((field) => [field, reconciled[field]])), + ...Object.fromEntries( + INCOME_AND_ASSET_FIELDS.map((field) => [field, reconciled[field]]), + ), year: reconciled.year, } } @@ -140,8 +248,14 @@ export function buildHouseholdPayload(inputs, metadata) { filing_status: normalized.filing_status, earned_income: 0, year: normalized.year, - childcare_expenses: normalized.childcare_expenses, - rent_annual: normalized.rent_annual, + county: normalized.county || null, + programs_mode: normalized.programs_mode, + selected_programs: normalized.selected_programs, + has_employer_health_insurance: normalized.has_employer_health_insurance, + ...Object.fromEntries(COST_FIELDS.map((field) => [field, normalized[field]])), + ...Object.fromEntries( + INCOME_AND_ASSET_FIELDS.map((field) => [field, normalized[field]]), + ), } } diff --git a/frontend/src/policyengineApi.js b/frontend/src/policyengineApi.js index 25dd07a..4b67a6b 100644 --- a/frontend/src/policyengineApi.js +++ b/frontend/src/policyengineApi.js @@ -75,7 +75,85 @@ const DEFAULT_CCDF_MODELED_STATES = new Set([ 'CA', 'CO', 'DE', 'MA', 'ME', 'NE', 'NH', 'PA', 'RI', 'VT', ]) +const DEFAULT_PUBLIC_ASSISTANCE_PROGRAM_OPTIONS = [ + { key: 'snap', label: 'Supplemental Nutrition Assistance Program (SNAP)' }, + { key: 'free_school_meals', label: 'Free or reduced price school meals' }, + { key: 'wic', label: 'Women, Infants, and Children Nutrition Program (WIC)' }, + { key: 'tanf', label: 'Temporary Assistance for Needy Families (TANF)' }, + { key: 'child_care_subsidies', label: 'Child Care Subsidy (CCDF)' }, + { key: 'head_start', label: 'Head Start' }, + { key: 'early_head_start', label: 'Early Head Start' }, + { key: 'housing_assistance', label: 'Section 8 Housing Choice Voucher' }, + { key: 'medicaid', label: 'Medicaid for adults' }, + { key: 'chip', label: 'Medicaid for children / CHIP' }, + { key: 'aca_ptc', label: 'Health Insurance Marketplace Subsidy' }, + { key: 'federal_refundable_credits', label: 'Federal refundable tax credits' }, + { key: 'state_refundable_credits', label: 'State refundable tax credits' }, + { key: 'ssi', label: 'Supplemental Security Income (SSI)' }, + { key: 'ssdi', label: 'Social Security Disability Insurance (SSDI)' }, +] + const DEFAULT_HOUSEHOLD_COST_DEFINITIONS = [ + { + key: 'rent', + label: 'Rent or mortgage', + short_label: 'Housing', + description: 'Annual rent or mortgage entered by the household.', + }, + { + key: 'utilities', + label: 'Utilities', + short_label: 'Utilities', + description: 'Annual utility costs entered by the household.', + }, + { + key: 'childcare', + label: 'Child care expense', + short_label: 'Child care', + description: 'Annual out-of-pocket child care expense entered by the household.', + }, + { + key: 'food', + label: 'Food', + short_label: 'Food', + description: 'Annual food costs entered by the household.', + }, + { + key: 'transportation', + label: 'Transportation', + short_label: 'Transport', + description: 'Annual transportation costs entered by the household.', + }, + { + key: 'health_insurance_premiums', + label: 'Health insurance premiums', + short_label: 'Health premiums', + description: 'Annual out-of-pocket health insurance premiums entered by the household.', + }, + { + key: 'technology', + label: 'Phone and internet', + short_label: 'Tech', + description: 'Annual phone and internet costs entered by the household.', + }, + { + key: 'debt_payments', + label: 'Debt payments', + short_label: 'Debt', + description: 'Annual debt payments entered by the household.', + }, + { + key: 'education_training', + label: 'Education and training', + short_label: 'Training', + description: 'Annual education or training costs entered by the household.', + }, + { + key: 'other_expenses', + label: 'Other expenses', + short_label: 'Other', + description: 'Other annual budget costs entered by the household.', + }, { key: 'chip_premium', label: 'CHIP premium', @@ -84,6 +162,19 @@ const DEFAULT_HOUSEHOLD_COST_DEFINITIONS = [ }, ] +const FIXED_HOUSEHOLD_COST_INPUTS = { + rent: 'rent_annual', + utilities: 'utility_expense_annual', + childcare: 'childcare_expenses', + food: 'food_expense_annual', + transportation: 'transportation_expense_annual', + health_insurance_premiums: 'health_insurance_premium_annual', + technology: 'technology_expense_annual', + debt_payments: 'debt_payment_annual', + education_training: 'education_expense_annual', + other_expenses: 'other_expense_annual', +} + function isCcdfModeledState(state, metadata) { const fromMetadata = metadata?.ccdf_modeled_states if (Array.isArray(fromMetadata) && fromMetadata.length) { @@ -105,6 +196,57 @@ const roundCurrency = (value) => Math.round((Number(value) || 0) * 100) / 100 const monthlyAmount = (value) => roundCurrency((Number(value) || 0) / 12) +const nonnegative = (value) => Math.max(0, Number(value) || 0) + +function normalizeCounty(county, state) { + if (!county) return null + const normalized = String(county) + .trim() + .toUpperCase() + .replaceAll(',', '') + .replaceAll('.', '') + .replaceAll('-', '_') + .replace(/\s+/g, '_') + + if (!normalized) return null + return normalized.endsWith(`_${state}`) ? normalized : `${normalized}_${state}` +} + +function getPublicAssistancePrograms(metadata) { + const options = metadata?.public_assistance_programs + if (Array.isArray(options) && options.length) { + return options + } + return DEFAULT_PUBLIC_ASSISTANCE_PROGRAM_OPTIONS +} + +function selectedPrograms(payload, metadata) { + const knownKeys = new Set(getPublicAssistancePrograms(metadata).map((program) => program.key)) + return new Set( + (payload?.selected_programs || []).filter((key) => knownKeys.has(key)), + ) +} + +function programIncluded(payload, key, metadata) { + const mode = payload?.programs_mode || 'all' + if (mode === 'none') return false + if (mode === 'custom') return selectedPrograms(payload, metadata).has(key) + return true +} + +function filterProgramValue(payload, key, value, metadata) { + return programIncluded(payload, key, metadata) ? value : 0 +} + +function fixedHouseholdCostsFromPayload(payload) { + return Object.fromEntries( + Object.entries(FIXED_HOUSEHOLD_COST_INPUTS).map(([key, field]) => [ + key, + roundCurrency(nonnegative(payload?.[field])), + ]), + ) +} + const REFUNDABLE_CREDIT_COMPONENTS = [ { key: 'eitc', variable: 'eitc', entity: 'tax_unit' }, { key: 'ctc', variable: 'refundable_ctc', entity: 'tax_unit' }, @@ -179,6 +321,18 @@ const getYearValue = (entity, variable, year) => entity?.[variable]?.[String(yea const getMonthValue = (entity, variable, year) => entity?.[variable]?.[`${year}-01`] +function sumPeopleYear(peopleResponse, descriptor, variable, year) { + return descriptor.people + .map((person) => Number(getYearValue(peopleResponse[person.id], variable, year)) || 0) + .reduce((sum, value) => sum + value, 0) +} + +function sumPeopleMonthAnnualized(peopleResponse, descriptor, variable, year) { + return descriptor.people + .map((person) => Number(getMonthValue(peopleResponse[person.id], variable, year)) || 0) + .reduce((sum, value) => sum + value, 0) * 12 +} + function buildRefundableCreditsFromResponse(taxUnit, peopleResponse, descriptor, year) { return Object.fromEntries( REFUNDABLE_CREDIT_COMPONENTS.map((component) => { @@ -252,6 +406,13 @@ function resolvePeople(people = [], filingStatus = 'SINGLE') { kind, age: Math.max(0, Number(member?.age) || 0), is_pregnant: Boolean(member?.is_pregnant), + is_disabled: Boolean(member?.is_disabled), + is_blind: Boolean(member?.is_blind), + is_full_time_student: Boolean(member?.is_full_time_student), + is_incapable_of_self_care: Boolean(member?.is_incapable_of_self_care), + earned_income: nonnegative(member?.earned_income), + ssi_amount: nonnegative(member?.ssi_amount), + ssdi_amount: nonnegative(member?.ssdi_amount), }) return } @@ -263,6 +424,13 @@ function resolvePeople(people = [], filingStatus = 'SINGLE') { kind, age: Math.max(0, Number(member?.age) || 0), is_pregnant: Boolean(member?.is_pregnant), + is_disabled: Boolean(member?.is_disabled), + is_blind: Boolean(member?.is_blind), + is_full_time_student: Boolean(member?.is_full_time_student), + is_incapable_of_self_care: Boolean(member?.is_incapable_of_self_care), + earned_income: nonnegative(member?.earned_income), + ssi_amount: nonnegative(member?.ssi_amount), + ssdi_amount: nonnegative(member?.ssdi_amount), }) }) @@ -332,15 +500,31 @@ function buildPersonData(person, year) { has_esi: { [year]: false }, offered_aca_disqualifying_esi: { [year]: false }, is_pregnant: { [year]: Boolean(person.is_pregnant) }, + is_disabled: { [year]: Boolean(person.is_disabled) }, + is_blind: { [year]: Boolean(person.is_blind) }, + is_full_time_student: { [year]: Boolean(person.is_full_time_student) }, + is_incapable_of_self_care: { [year]: Boolean(person.is_incapable_of_self_care) }, under_60_days_postpartum: { [year]: false }, immigration_status_str: { [year]: 'CITIZEN' }, is_ccdf_reason_for_care_eligible: { [year]: true }, + takes_up_medicaid_if_eligible: { [year]: true }, + takes_up_chip_if_eligible: { [year]: true }, + takes_up_ssi_if_eligible: { [year]: true }, + takes_up_head_start_if_eligible: { [year]: true }, + takes_up_early_head_start_if_eligible: { [year]: true }, + is_enrolled_in_ccdf: { [year]: true }, + is_enrolled_in_head_start: { [year]: true }, + receives_wic: { [`${year}-01`]: true }, is_aca_ptc_eligible: { [year]: null }, is_medicaid_eligible: { [year]: null }, is_chip_eligible: { [year]: null }, wic: { [`${year}-01`]: null }, medicaid: { [year]: null }, chip: { [year]: null }, + head_start: { [year]: null }, + early_head_start: { [year]: null }, + ssi: { [year]: null }, + social_security_disability: { [year]: null }, } if (person.kind === 'adult') { data.ccdf_age_group = { [year]: 'SCHOOL_AGE' } @@ -361,23 +545,45 @@ function buildSituation(payload, options = {}) { const descriptor = describeHousehold(people) const filingStatus = effectiveFilingStatus(payload) const earnedIncome = Number(payload.earned_income) || 0 - const monthlyEarnedIncome = earnedIncome / 12 const memberIds = descriptor.people.map((person) => person.id) - const childcareExpenses = Math.max(0, Number(payload.childcare_expenses) || 0) - const rentAnnual = Math.max(0, Number(payload.rent_annual) || 0) + const childcareExpenses = nonnegative(payload.childcare_expenses) + const rentAnnual = nonnegative(payload.rent_annual) + const utilityExpenseAnnual = nonnegative(payload.utility_expense_annual) + const healthInsurancePremiumAnnual = nonnegative( + payload.health_insurance_premium_annual, + ) const ccdfModeled = isCcdfModeledState(payload.state, options.metadata) const spmUnitEntity = { members: [...memberIds], snap: { [`${year}-01`]: null }, free_school_meals: { [year]: null }, + housing_assistance: { [year]: null }, meets_ccdf_activity_test: { [year]: true }, + takes_up_snap_if_eligible: { [year]: programIncluded(payload, 'snap', options.metadata) }, + takes_up_tanf_if_eligible: { [year]: programIncluded(payload, 'tanf', options.metadata) }, + receives_housing_assistance: { [year]: programIncluded(payload, 'housing_assistance', options.metadata) }, } if (ccdfModeled) { spmUnitEntity.child_care_subsidies = { [year]: null } } if (childcareExpenses > 0) { spmUnitEntity.childcare_expenses = { [year]: childcareExpenses } + spmUnitEntity.spm_unit_pre_subsidy_childcare_expenses = { [year]: childcareExpenses } + } + if (utilityExpenseAnnual > 0) { + spmUnitEntity.utility_expense = { [year]: utilityExpenseAnnual } + } + if (nonnegative(payload.liquid_assets) > 0) { + spmUnitEntity.snap_assets = { [year]: nonnegative(payload.liquid_assets) } + } + if (programIncluded(payload, 'tanf', options.metadata)) { + spmUnitEntity.is_tanf_enrolled = Object.fromEntries( + Array.from({ length: 12 }, (_, month) => [ + `${year}-${String(month + 1).padStart(2, '0')}`, + true, + ]), + ) } const situation = { @@ -393,17 +599,25 @@ function buildSituation(payload, options = {}) { tax_unit_fpg: { [year]: null }, income_tax_refundable_credits: { [year]: null }, premium_tax_credit: { [`${year}-01`]: null }, + takes_up_aca_if_eligible: { [year]: programIncluded(payload, 'aca_ptc', options.metadata) }, + takes_up_eitc: { [year]: programIncluded(payload, 'federal_refundable_credits', options.metadata) }, }, }, households: { household: { members: [...memberIds], state_name: { [year]: payload.state }, + county: normalizeCounty(payload.county, payload.state) + ? { [year]: normalizeCounty(payload.county, payload.state) } + : undefined, household_market_income: { [year]: null }, household_tax_before_refundable_credits: { [year]: null }, household_state_tax_before_refundable_credits: { [year]: null }, household_refundable_state_tax_credits: { [year]: null }, chip_premium: { [year]: null }, + hud_utility_allowance: utilityExpenseAnnual > 0 + ? { [year]: utilityExpenseAnnual } + : undefined, }, }, marital_units: {}, @@ -420,6 +634,29 @@ function buildSituation(payload, options = {}) { situation.spm_units.spm_unit[tanfVariable] = { [`${year}-01`]: null } } + const setEarnedIncome = (personData, amount) => { + const annualAmount = Number(amount) || 0 + const monthlyAmountValue = annualAmount / 12 + personData.employment_income = { [year]: annualAmount } + personData.tanf_gross_earned_income = Object.fromEntries( + Array.from({ length: 12 }, (_, month) => [ + `${year}-${String(month + 1).padStart(2, '0')}`, + monthlyAmountValue, + ]), + ) + + const stateSpecificEarnedIncomeVariable = + STATE_TANF_EARNED_INCOME_VARIABLES[payload.state] + if (stateSpecificEarnedIncomeVariable) { + personData[stateSpecificEarnedIncomeVariable] = Object.fromEntries( + Array.from({ length: 12 }, (_, month) => [ + `${year}-${String(month + 1).padStart(2, '0')}`, + monthlyAmountValue, + ]), + ) + } + } + descriptor.people.forEach((person, index) => { const personData = buildPersonData(person, year) @@ -429,39 +666,98 @@ function buildSituation(payload, options = {}) { personData[component.variable] = { [year]: null } }) + personData.takes_up_medicaid_if_eligible = { + [year]: programIncluded(payload, 'medicaid', options.metadata), + } + personData.takes_up_chip_if_eligible = { + [year]: programIncluded(payload, 'chip', options.metadata), + } + personData.takes_up_ssi_if_eligible = { + [year]: programIncluded(payload, 'ssi', options.metadata), + } + personData.takes_up_head_start_if_eligible = { + [year]: programIncluded(payload, 'head_start', options.metadata), + } + personData.takes_up_early_head_start_if_eligible = { + [year]: programIncluded(payload, 'early_head_start', options.metadata), + } + personData.is_enrolled_in_ccdf = { + [year]: programIncluded(payload, 'child_care_subsidies', options.metadata), + } + personData.is_enrolled_in_head_start = { + [year]: programIncluded(payload, 'head_start', options.metadata), + } + personData.receives_wic = Object.fromEntries( + Array.from({ length: 12 }, (_, month) => [ + `${year}-${String(month + 1).padStart(2, '0')}`, + programIncluded(payload, 'wic', options.metadata), + ]), + ) + + if (payload.has_employer_health_insurance) { + personData.has_esi = { [year]: true } + personData.offered_aca_disqualifying_esi = { [year]: true } + } + if (index === 0 && rentAnnual > 0) { - personData.rent = { [year]: rentAnnual } + personData.pre_subsidy_rent = { [year]: rentAnnual } } - if (includeIncomeOverrides && index === 0 && earnedIncome > 0) { - personData.employment_income = { [year]: earnedIncome } - personData.tanf_gross_earned_income = Object.fromEntries( - Array.from({ length: 12 }, (_, month) => [ - `${year}-${String(month + 1).padStart(2, '0')}`, - monthlyEarnedIncome, - ]), - ) + if (index === 0 && healthInsurancePremiumAnnual > 0) { + personData.health_insurance_premiums = { [year]: healthInsurancePremiumAnnual } + } - const stateSpecificEarnedIncomeVariable = - STATE_TANF_EARNED_INCOME_VARIABLES[payload.state] - if (stateSpecificEarnedIncomeVariable) { - personData[stateSpecificEarnedIncomeVariable] = Object.fromEntries( - Array.from({ length: 12 }, (_, month) => [ - `${year}-${String(month + 1).padStart(2, '0')}`, - monthlyEarnedIncome, - ]), - ) - } + if (person.ssi_amount > 0 && programIncluded(payload, 'ssi', options.metadata)) { + personData.ssi = { [year]: person.ssi_amount } + } + + if (person.ssdi_amount > 0 && programIncluded(payload, 'ssdi', options.metadata)) { + personData.social_security_disability = { [year]: person.ssdi_amount } + } + + if (includeIncomeOverrides && index === 0) { + setEarnedIncome(personData, earnedIncome) } else if (!includeIncomeOverrides && index === 0) { personData.employment_income = { [year]: null } + } else if (person.earned_income > 0) { + setEarnedIncome(personData, person.earned_income) + } + + if (index === 0) { + const extraIncomeInputs = { + self_employment_income: payload.self_employment_income_annual, + child_support_received: payload.child_support_annual, + taxable_interest_income: payload.taxable_interest_income_annual, + dividend_income: payload.dividend_income_annual, + rental_income: payload.rental_income_annual, + unemployment_compensation: payload.unemployment_compensation_annual, + pension_income: payload.pension_income_annual, + social_security: payload.social_security_annual, + miscellaneous_income: payload.miscellaneous_income_annual, + } + Object.entries(extraIncomeInputs).forEach(([variable, amount]) => { + const annualAmount = nonnegative(amount) + if (annualAmount > 0) { + personData[variable] = { [year]: annualAmount } + } + }) } situation.people[person.id] = personData }) if (includeIncomeOverrides) { - situation.tax_units.tax_unit.aca_magi = { [year]: earnedIncome } - situation.tax_units.tax_unit.medicaid_magi = { [year]: earnedIncome } + const estimatedMagi = earnedIncome + + descriptor.people.slice(1).reduce((sum, person) => sum + nonnegative(person.earned_income), 0) + + nonnegative(payload.self_employment_income_annual) + + nonnegative(payload.taxable_interest_income_annual) + + nonnegative(payload.dividend_income_annual) + + nonnegative(payload.rental_income_annual) + + nonnegative(payload.unemployment_compensation_annual) + + nonnegative(payload.pension_income_annual) + + nonnegative(payload.miscellaneous_income_annual) + situation.tax_units.tax_unit.aca_magi = { [year]: estimatedMagi } + situation.tax_units.tax_unit.medicaid_magi = { [year]: estimatedMagi } if (payload.state === 'CO' && earnedIncome > 0) { situation.spm_units.spm_unit.co_tanf_countable_gross_earned_income = { [year]: earnedIncome, @@ -576,7 +872,15 @@ function getStateName(metadata, stateCode) { } function getProgramDefinitions(metadata) { - return metadata?.programs || [] + const definitions = metadata?.programs + if (Array.isArray(definitions) && definitions.length) { + return definitions + } + return DEFAULT_PUBLIC_ASSISTANCE_PROGRAM_OPTIONS.map((program) => ({ + ...program, + short_label: program.label, + description: '', + })) } function getHouseholdCostDefinitions(metadata) { @@ -649,22 +953,15 @@ export function buildHouseholdResultFromResponse(payload, metadata, apiResponse, Math.max(0, taxes - stateTaxesBeforeRefundableCredits), ) const snap = roundCurrency((Number(getMonthValue(spmUnit, 'snap', year)) || 0) * 12) - const wic = roundCurrency( - Object.values(peopleResponse) - .map((person) => Number(getMonthValue(person, 'wic', year)) || 0) - .reduce((sum, value) => sum + value, 0) * 12, - ) + const wic = roundCurrency(sumPeopleMonthAnnualized(peopleResponse, descriptor, 'wic', year)) const freeSchoolMeals = roundCurrency(getYearValue(spmUnit, 'free_school_meals', year)) - const medicaid = roundCurrency( - Object.values(peopleResponse) - .map((person) => Number(getYearValue(person, 'medicaid', year)) || 0) - .reduce((sum, value) => sum + value, 0), - ) - const chip = roundCurrency( - Object.values(peopleResponse) - .map((person) => Number(getYearValue(person, 'chip', year)) || 0) - .reduce((sum, value) => sum + value, 0), - ) + const headStart = roundCurrency(sumPeopleYear(peopleResponse, descriptor, 'head_start', year)) + const earlyHeadStart = roundCurrency(sumPeopleYear(peopleResponse, descriptor, 'early_head_start', year)) + const housingAssistance = roundCurrency(getYearValue(spmUnit, 'housing_assistance', year)) + const ssi = roundCurrency(sumPeopleYear(peopleResponse, descriptor, 'ssi', year)) + const ssdi = roundCurrency(sumPeopleYear(peopleResponse, descriptor, 'social_security_disability', year)) + const medicaid = roundCurrency(sumPeopleYear(peopleResponse, descriptor, 'medicaid', year)) + const chip = roundCurrency(sumPeopleYear(peopleResponse, descriptor, 'chip', year)) const acaPtc = roundCurrency((Number(getMonthValue(taxUnit, 'premium_tax_credit', year)) || 0) * 12) const federalRefundableCredits = roundCurrency( getYearValue(taxUnit, 'income_tax_refundable_credits', year), @@ -683,18 +980,54 @@ export function buildHouseholdResultFromResponse(payload, metadata, apiResponse, : 0 const taxUnitFpg = roundCurrency(getYearValue(taxUnit, 'tax_unit_fpg', year)) const programs = { - snap, - tanf, - wic, - free_school_meals: freeSchoolMeals, - child_care_subsidies: childCareSubsidies, - medicaid, - chip, - aca_ptc: acaPtc, - federal_refundable_credits: federalRefundableCredits, - state_refundable_credits: stateRefundableCredits, + snap: roundCurrency(filterProgramValue(payload, 'snap', snap, metadata)), + tanf: roundCurrency(filterProgramValue(payload, 'tanf', tanf, metadata)), + wic: roundCurrency(filterProgramValue(payload, 'wic', wic, metadata)), + free_school_meals: roundCurrency(filterProgramValue( + payload, + 'free_school_meals', + freeSchoolMeals, + metadata, + )), + head_start: roundCurrency(filterProgramValue(payload, 'head_start', headStart, metadata)), + early_head_start: roundCurrency(filterProgramValue( + payload, + 'early_head_start', + earlyHeadStart, + metadata, + )), + child_care_subsidies: roundCurrency(filterProgramValue( + payload, + 'child_care_subsidies', + childCareSubsidies, + metadata, + )), + housing_assistance: roundCurrency(filterProgramValue( + payload, + 'housing_assistance', + housingAssistance, + metadata, + )), + ssi: roundCurrency(filterProgramValue(payload, 'ssi', ssi, metadata)), + ssdi: roundCurrency(filterProgramValue(payload, 'ssdi', ssdi, metadata)), + medicaid: roundCurrency(filterProgramValue(payload, 'medicaid', medicaid, metadata)), + chip: roundCurrency(filterProgramValue(payload, 'chip', chip, metadata)), + aca_ptc: roundCurrency(filterProgramValue(payload, 'aca_ptc', acaPtc, metadata)), + federal_refundable_credits: roundCurrency(filterProgramValue( + payload, + 'federal_refundable_credits', + federalRefundableCredits, + metadata, + )), + state_refundable_credits: roundCurrency(filterProgramValue( + payload, + 'state_refundable_credits', + stateRefundableCredits, + metadata, + )), } const householdCosts = { + ...fixedHouseholdCostsFromPayload(payload), chip_premium: chipPremium, } @@ -745,14 +1078,44 @@ export function buildHouseholdResultFromResponse(payload, metadata, apiResponse, state: payload.state, earned_income: payload.earned_income, year: payload.year, - county: null, + county: payload.county || null, household_type: null, filing_status: effectiveFilingStatus(payload), people: descriptor.people.map((person) => ({ kind: person.kind, age: person.age, is_pregnant: person.is_pregnant, + is_disabled: person.is_disabled, + is_blind: person.is_blind, + is_full_time_student: person.is_full_time_student, + is_incapable_of_self_care: person.is_incapable_of_self_care, + earned_income: person.earned_income, + ssi_amount: person.ssi_amount, + ssdi_amount: person.ssdi_amount, })), + programs_mode: payload.programs_mode || 'all', + selected_programs: payload.selected_programs || [], + has_employer_health_insurance: Boolean(payload.has_employer_health_insurance), + childcare_expenses: nonnegative(payload.childcare_expenses), + rent_annual: nonnegative(payload.rent_annual), + utility_expense_annual: nonnegative(payload.utility_expense_annual), + food_expense_annual: nonnegative(payload.food_expense_annual), + transportation_expense_annual: nonnegative(payload.transportation_expense_annual), + health_insurance_premium_annual: nonnegative(payload.health_insurance_premium_annual), + technology_expense_annual: nonnegative(payload.technology_expense_annual), + debt_payment_annual: nonnegative(payload.debt_payment_annual), + education_expense_annual: nonnegative(payload.education_expense_annual), + other_expense_annual: nonnegative(payload.other_expense_annual), + self_employment_income_annual: nonnegative(payload.self_employment_income_annual), + child_support_annual: nonnegative(payload.child_support_annual), + taxable_interest_income_annual: nonnegative(payload.taxable_interest_income_annual), + dividend_income_annual: nonnegative(payload.dividend_income_annual), + rental_income_annual: nonnegative(payload.rental_income_annual), + unemployment_compensation_annual: nonnegative(payload.unemployment_compensation_annual), + pension_income_annual: nonnegative(payload.pension_income_annual), + social_security_annual: nonnegative(payload.social_security_annual), + miscellaneous_income_annual: nonnegative(payload.miscellaneous_income_annual), + liquid_assets: nonnegative(payload.liquid_assets), }, template: { id: descriptor.id, @@ -797,7 +1160,10 @@ export function buildCliffDrivers(previousPoint, currentPoint, metadata) { const labelByKey = getProgramLabelMap(metadata) const householdCostLabels = getHouseholdCostLabelMap(metadata) const drivers = Object.keys(labelByKey).flatMap((key) => { - const changeAnnual = roundCurrency(currentPoint.programs[key] - previousPoint.programs[key]) + const changeAnnual = roundCurrency( + (Number(currentPoint.programs?.[key]) || 0) + - (Number(previousPoint.programs?.[key]) || 0), + ) if (changeAnnual >= 0) { return [] } @@ -885,6 +1251,38 @@ export function buildSeriesDataFromResponse(payload, metadata, apiResponse, desc getYearValue(spmUnit, 'free_school_meals', year), pointCount, ) + const headStartValues = sumArrays( + descriptor.people.map((person) => asArray( + getYearValue(peopleResponse[person.id], 'head_start', year), + pointCount, + )), + pointCount, + ) + const earlyHeadStartValues = sumArrays( + descriptor.people.map((person) => asArray( + getYearValue(peopleResponse[person.id], 'early_head_start', year), + pointCount, + )), + pointCount, + ) + const housingAssistanceValues = asArray( + getYearValue(spmUnit, 'housing_assistance', year), + pointCount, + ) + const ssiValues = sumArrays( + descriptor.people.map((person) => asArray( + getYearValue(peopleResponse[person.id], 'ssi', year), + pointCount, + )), + pointCount, + ) + const ssdiValues = sumArrays( + descriptor.people.map((person) => asArray( + getYearValue(peopleResponse[person.id], 'social_security_disability', year), + pointCount, + )), + pointCount, + ) const premiumTaxCreditValues = asArray( getMonthValue(taxUnit, 'premium_tax_credit', year), pointCount, @@ -928,21 +1326,63 @@ export function buildSeriesDataFromResponse(payload, metadata, apiResponse, desc getYearValue(households, 'chip_premium', year), pointCount, ) + const fixedHouseholdCosts = fixedHouseholdCostsFromPayload(payload) const points = earnedIncomeValues.map((earnedIncome, index) => { const programs = { - snap: roundCurrency(snapValues[index]), - tanf: roundCurrency(tanfValues[index]), - wic: roundCurrency(wicValues[index]), - free_school_meals: roundCurrency(freeSchoolMealValues[index]), - child_care_subsidies: roundCurrency(childCareSubsidyValues[index]), - medicaid: roundCurrency(medicaidValues[index]), - chip: roundCurrency(chipValues[index]), - aca_ptc: roundCurrency(premiumTaxCreditValues[index]), - federal_refundable_credits: roundCurrency(federalRefundableCreditValues[index]), - state_refundable_credits: roundCurrency(stateRefundableCreditValues[index]), + snap: roundCurrency(filterProgramValue(payload, 'snap', snapValues[index], metadata)), + tanf: roundCurrency(filterProgramValue(payload, 'tanf', tanfValues[index], metadata)), + wic: roundCurrency(filterProgramValue(payload, 'wic', wicValues[index], metadata)), + free_school_meals: roundCurrency(filterProgramValue( + payload, + 'free_school_meals', + freeSchoolMealValues[index], + metadata, + )), + head_start: roundCurrency(filterProgramValue( + payload, + 'head_start', + headStartValues[index], + metadata, + )), + early_head_start: roundCurrency(filterProgramValue( + payload, + 'early_head_start', + earlyHeadStartValues[index], + metadata, + )), + child_care_subsidies: roundCurrency(filterProgramValue( + payload, + 'child_care_subsidies', + childCareSubsidyValues[index], + metadata, + )), + housing_assistance: roundCurrency(filterProgramValue( + payload, + 'housing_assistance', + housingAssistanceValues[index], + metadata, + )), + ssi: roundCurrency(filterProgramValue(payload, 'ssi', ssiValues[index], metadata)), + ssdi: roundCurrency(filterProgramValue(payload, 'ssdi', ssdiValues[index], metadata)), + medicaid: roundCurrency(filterProgramValue(payload, 'medicaid', medicaidValues[index], metadata)), + chip: roundCurrency(filterProgramValue(payload, 'chip', chipValues[index], metadata)), + aca_ptc: roundCurrency(filterProgramValue(payload, 'aca_ptc', premiumTaxCreditValues[index], metadata)), + federal_refundable_credits: roundCurrency(filterProgramValue( + payload, + 'federal_refundable_credits', + federalRefundableCreditValues[index], + metadata, + )), + state_refundable_credits: roundCurrency(filterProgramValue( + payload, + 'state_refundable_credits', + stateRefundableCreditValues[index], + metadata, + )), } const householdCosts = { + ...fixedHouseholdCosts, chip_premium: roundCurrency(chipPremiumValues[index]), } const marketIncome = roundCurrency(marketIncomeValues[index]) @@ -995,6 +1435,11 @@ export function buildSeriesDataFromResponse(payload, metadata, apiResponse, desc aca_ptc: point.programs.aca_ptc, snap: point.programs.snap, free_school_meals: point.programs.free_school_meals, + head_start: point.programs.head_start, + early_head_start: point.programs.early_head_start, + housing_assistance: point.programs.housing_assistance, + ssi: point.programs.ssi, + ssdi: point.programs.ssdi, federal_refundable_credits: point.programs.federal_refundable_credits, state_refundable_credits: point.programs.state_refundable_credits, federal_taxes_before_refundable_credits: point.totals.federal_taxes_before_refundable_credits, @@ -1027,14 +1472,14 @@ export function buildSeriesDataFromResponse(payload, metadata, apiResponse, desc } export async function calculateHouseholdViaPolicyEngine(payload, metadata) { - const baseSituation = buildSituation(payload, { includeIncomeOverrides: true }) + const baseSituation = buildSituation(payload, { includeIncomeOverrides: true, metadata }) const delta = metadata?.defaults?.cliff_delta || 1000 const bumpedSituation = buildSituation( { ...payload, earned_income: payload.earned_income + delta, }, - { includeIncomeOverrides: true }, + { includeIncomeOverrides: true, metadata }, ) const [baseResponse, bumpedResponse] = await Promise.all([ diff --git a/frontend/src/utils/urlState.js b/frontend/src/utils/urlState.js index 150978c..7d55b6b 100644 --- a/frontend/src/utils/urlState.js +++ b/frontend/src/utils/urlState.js @@ -1,13 +1,83 @@ -const PERSON_PATTERN = /^(\d+)([ac])(p)?$/i +const LEGACY_PERSON_PATTERN = /^(\d+)([ac])(p)?$/i + +const PERSON_FLAG_KEYS = { + p: 'is_pregnant', + d: 'is_disabled', + b: 'is_blind', + s: 'is_full_time_student', + x: 'is_incapable_of_self_care', +} + +const NUMERIC_FIELDS = [ + ['cc', 'childcare_expenses'], + ['rent', 'rent_annual'], + ['util', 'utility_expense_annual'], + ['food', 'food_expense_annual'], + ['trans', 'transportation_expense_annual'], + ['hp', 'health_insurance_premium_annual'], + ['tech', 'technology_expense_annual'], + ['debt', 'debt_payment_annual'], + ['edu', 'education_expense_annual'], + ['oe', 'other_expense_annual'], + ['se', 'self_employment_income_annual'], + ['cs', 'child_support_annual'], + ['int', 'taxable_interest_income_annual'], + ['div', 'dividend_income_annual'], + ['ri', 'rental_income_annual'], + ['ui', 'unemployment_compensation_annual'], + ['pen', 'pension_income_annual'], + ['ss', 'social_security_annual'], + ['mi', 'miscellaneous_income_annual'], + ['assets', 'liquid_assets'], + ['max', 'chart_max_earned_income'], +] + +const parseNonnegative = (value) => { + const n = Number(value) + return Number.isFinite(n) && n >= 0 ? n : null +} function encodePerson(person) { const kind = person.kind === 'adult' ? 'a' : 'c' - const pregnant = person.is_pregnant ? 'p' : '' - return `${Math.max(0, Number(person.age) || 0)}${kind}${pregnant}` + const flags = Object.entries(PERSON_FLAG_KEYS) + .filter(([, field]) => Boolean(person[field])) + .map(([flag]) => flag) + .join('') + return [ + Math.max(0, Number(person.age) || 0), + kind, + flags, + Math.round(Number(person.earned_income) || 0), + Math.round(Number(person.ssi_amount) || 0), + Math.round(Number(person.ssdi_amount) || 0), + ].join(':') } function decodePerson(token) { - const match = token.trim().match(PERSON_PATTERN) + const value = token.trim() + if (value.includes(':')) { + const [age, kind, flags = '', earnedIncome, ssiAmount, ssdiAmount] = value.split(':') + const person = { + age: parseInt(age, 10), + kind: kind?.toLowerCase() === 'a' ? 'adult' : 'child', + is_pregnant: false, + is_disabled: false, + is_blind: false, + is_full_time_student: false, + is_incapable_of_self_care: false, + earned_income: parseNonnegative(earnedIncome) || 0, + ssi_amount: parseNonnegative(ssiAmount) || 0, + ssdi_amount: parseNonnegative(ssdiAmount) || 0, + } + if (!Number.isFinite(person.age)) return null + flags.split('').forEach((flag) => { + const field = PERSON_FLAG_KEYS[flag] + if (field) person[field] = true + }) + return person + } + + const match = value.match(LEGACY_PERSON_PATTERN) if (!match) return null return { age: parseInt(match[1], 10), @@ -18,27 +88,45 @@ function decodePerson(token) { export function encodeInputs(inputs) { if (!inputs) return '' - const parts = [] + const params = new URLSearchParams() if (inputs.state) { - parts.push(`s=${encodeURIComponent(inputs.state)}`) + params.set('s', inputs.state) + } + + if (inputs.county) { + params.set('county', inputs.county) + } + + if (inputs.filing_status) { + params.set('fs', inputs.filing_status) } const people = (inputs.people || []).map(encodePerson).filter(Boolean) if (people.length) { - parts.push(`p=${people.join(',')}`) + params.set('p', people.join(',')) } - const childcare = Number(inputs.childcare_expenses) || 0 - if (childcare > 0) parts.push(`cc=${Math.round(childcare)}`) + if (inputs.programs_mode && inputs.programs_mode !== 'all') { + params.set('pm', inputs.programs_mode) + } - const rent = Number(inputs.rent_annual) || 0 - if (rent > 0) parts.push(`rent=${Math.round(rent)}`) + if (inputs.programs_mode === 'custom' && inputs.selected_programs?.length) { + params.set('sp', inputs.selected_programs.join(',')) + } - const chartMax = Number(inputs.chart_max_earned_income) || 0 - if (chartMax > 0) parts.push(`max=${Math.round(chartMax)}`) + if (inputs.has_employer_health_insurance) { + params.set('esi', '1') + } - return parts.join('&') + NUMERIC_FIELDS.forEach(([param, field]) => { + const value = Number(inputs[field]) || 0 + if (value > 0) { + params.set(param, String(Math.round(value))) + } + }) + + return params.toString() } export function decodeInputs(search) { @@ -52,26 +140,39 @@ export function decodeInputs(search) { decoded.state = params.get('s').toUpperCase() } + if (params.has('county')) { + decoded.county = params.get('county') + } + + if (params.has('fs')) { + decoded.filing_status = params.get('fs') + } + if (params.has('p')) { const people = params.get('p').split(',').map(decodePerson).filter(Boolean) if (people.length) decoded.people = people } - if (params.has('cc')) { - const n = Number(params.get('cc')) - if (Number.isFinite(n) && n >= 0) decoded.childcare_expenses = n + if (params.has('pm')) { + decoded.programs_mode = params.get('pm') } - if (params.has('rent')) { - const n = Number(params.get('rent')) - if (Number.isFinite(n) && n >= 0) decoded.rent_annual = n + if (params.has('sp')) { + decoded.selected_programs = params.get('sp').split(',').filter(Boolean) } - if (params.has('max')) { - const n = Number(params.get('max')) - if (Number.isFinite(n) && n >= 10000) decoded.chart_max_earned_income = n + if (params.has('esi')) { + decoded.has_employer_health_insurance = params.get('esi') === '1' } + NUMERIC_FIELDS.forEach(([param, field]) => { + if (!params.has(param)) return + const value = parseNonnegative(params.get(param)) + if (value === null) return + if (field === 'chart_max_earned_income' && value < 10000) return + decoded[field] = value + }) + return Object.keys(decoded).length ? decoded : null } diff --git a/tests/test_atlanta_fed_input_parity.py b/tests/test_atlanta_fed_input_parity.py new file mode 100644 index 0000000..423a5e1 --- /dev/null +++ b/tests/test_atlanta_fed_input_parity.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from cliff_watch.calculator import ( + build_household_situation, + calculate_household_types, + household_input_from_dict, +) + + +def _parity_payload() -> dict: + return { + "state": "GA", + "county": "Fulton", + "earned_income": 30_000, + "year": 2026, + "filing_status": "JOINT", + "people": [ + { + "kind": "adult", + "age": 34, + "is_pregnant": True, + "is_disabled": True, + "is_blind": True, + "is_full_time_student": True, + "ssi_amount": 1_200, + "ssdi_amount": 2_400, + }, + { + "kind": "adult", + "age": 36, + "earned_income": 12_000, + }, + { + "kind": "child", + "age": 4, + "is_incapable_of_self_care": True, + }, + ], + "childcare_expenses": 6_000, + "rent_annual": 18_000, + "utility_expense_annual": 2_400, + "food_expense_annual": 7_200, + "transportation_expense_annual": 4_800, + "health_insurance_premium_annual": 1_800, + "technology_expense_annual": 1_200, + "debt_payment_annual": 900, + "education_expense_annual": 600, + "other_expense_annual": 300, + "self_employment_income_annual": 3_000, + "child_support_annual": 2_000, + "taxable_interest_income_annual": 100, + "dividend_income_annual": 200, + "rental_income_annual": 300, + "unemployment_compensation_annual": 400, + "pension_income_annual": 500, + "social_security_annual": 600, + "miscellaneous_income_annual": 700, + "liquid_assets": 1_500, + "has_employer_health_insurance": True, + "programs_mode": "custom", + "selected_programs": ["snap", "housing_assistance", "ssi", "ssdi"], + } + + +def test_atlanta_fed_parity_inputs_reach_policyengine_situation() -> None: + payload = household_input_from_dict(_parity_payload()) + situation = build_household_situation(payload) + + adult_1 = situation["people"]["adult_1"] + adult_2 = situation["people"]["adult_2"] + child_1 = situation["people"]["child_1"] + spm_unit = situation["spm_units"]["spm_unit"] + tax_unit = situation["tax_units"]["tax_unit"] + household = situation["households"]["household"] + + assert household["county"][2026] == "FULTON_GA" + assert household["hud_utility_allowance"][2026] == 2_400 + + assert adult_1["is_pregnant"][2026] is True + assert adult_1["is_disabled"][2026] is True + assert adult_1["is_blind"][2026] is True + assert adult_1["is_full_time_student"][2026] is True + assert adult_1["pre_subsidy_rent"][2026] == 18_000 + assert adult_1["health_insurance_premiums"][2026] == 1_800 + assert adult_1["ssi"][2026] == 1_200 + assert adult_1["social_security_disability"][2026] == 2_400 + assert adult_1["takes_up_medicaid_if_eligible"][2026] is False + assert adult_1["takes_up_ssi_if_eligible"][2026] is True + + assert adult_2["employment_income"][2026] == 12_000 + assert child_1["is_incapable_of_self_care"][2026] is True + + assert spm_unit["takes_up_snap_if_eligible"][2026] is True + assert spm_unit["takes_up_tanf_if_eligible"][2026] is False + assert spm_unit["receives_housing_assistance"][2026] is True + assert spm_unit["childcare_expenses"][2026] == 6_000 + assert spm_unit["utility_expense"][2026] == 2_400 + assert spm_unit["snap_assets"][2026] == 1_500 + + assert tax_unit["takes_up_aca_if_eligible"][2026] is False + assert tax_unit["aca_magi"][2026] == 47_200 + + +def test_household_type_comparison_preserves_advanced_inputs(monkeypatch) -> None: + payload = household_input_from_dict(_parity_payload()) + captured = [] + + def fake_simulate_core(scenario): + captured.append(scenario) + return { + "template": { + "label": "Example", + "short_label": "Ex", + "description": "Example household.", + }, + "totals": { + "net_resources": 1_200, + "core_support": 600, + "taxes": 100, + }, + "counts": { + "num_adults": 1, + "num_children": 0, + "household_size": 1, + }, + } + + monkeypatch.setattr( + "cliff_watch.calculator._simulate_core", + fake_simulate_core, + ) + + calculate_household_types(payload) + + assert captured + assert all(scenario.people == () for scenario in captured) + assert all(scenario.selected_programs == payload.selected_programs for scenario in captured) + assert all(scenario.rent_annual == 18_000 for scenario in captured) + assert all(scenario.programs_mode == "custom" for scenario in captured)