src/programs/snap-negative/field-catalog.ts
Linked repository file from this build.
import { isCalendarDate } from "./input-limits";
import natureCatalog from "./nature-catalog.json";
import type { ReviewAnswers } from "./types";
/** Workbook cell reference and display label for a review answer field. */
export interface FieldDefinition {
key: keyof ReviewAnswers;
item: string;
cell: string;
}
/** Coded choice with workbook value and short label. */
export interface FieldChoice {
value: string;
label: string;
}
export const reviewFieldDefinitions: FieldDefinition[] = [
{ key: "telephone", item: "B. Telephone number", cell: "V9" },
{ key: "actualAddress", item: "D. Actual address / directions", cell: "V12" },
{ key: "dateAssigned", item: "E. Date assigned", cell: "C16" },
{ key: "dateCompleted", item: "F. Date completed / disposed of", cell: "O16" },
{ key: "dateCleared", item: "G. Date cleared", cell: "AA16" },
{ key: "stratum", item: "5. Stratum", cell: "C24" },
{ key: "actionType", item: "8. Action Type", cell: "AE24" },
{ key: "caseClassification", item: "9. Case Classification", cell: "AJ24" },
{ key: "disposition", item: "10a. Disposition of Review", cell: "F29" },
{ key: "nstrReason", item: "10b. NSTR Reason", cell: "J29" },
{ key: "finding", item: "11. Finding", cell: "M29" },
{ key: "recordedReason", item: "12a. Recorded Reason for Action", cell: "Q29" },
{ key: "recordedReasonAccuracy", item: "12b. Accuracy of Recorded Reason", cell: "AF29" },
{ key: "noticeRequirement", item: "13. Notice Requirement", cell: "AL29" },
{ key: "noticeLanguage", item: "14a. Required Language", cell: "J33" },
{ key: "noticeClearUnderstandable", item: "14b. Specific, Clear, Understandable", cell: "U33" },
{ key: "noticeReasonToHh", item: "14c. Reason to Household", cell: "AA33" },
{ key: "noticeReasonAccuracy", item: "14d. Accuracy of Reason to HH", cell: "AF33" },
{ key: "missedInterviewNotice", item: "15a. Notice of Missed Interview", cell: "J37" },
{ key: "contactRequest", item: "15b. Request for Contact", cell: "R37" },
{ key: "verificationRequest", item: "15c. Request for Verification", cell: "V37" },
{ key: "periodicReport", item: "15d. Periodic Report", cell: "AF37" },
{ key: "actionTimeliness", item: "16a. Timeliness of Action", cell: "J41" },
{ key: "daysActionEarlyLate", item: "16b. Days Action Early / Late", cell: "U41" },
{ key: "noticeTimeliness", item: "16c. Timeliness of Notice", cell: "AA41" },
{ key: "daysNoticeLate", item: "16d. Days Notice Late", cell: "AF41" },
{ key: "contactResponse", item: "19. Collateral / Household Contact", cell: "E52" },
{ key: "actionInitiatedBy", item: "20. Action Initiated By", cell: "N52" },
{ key: "narrativePreset", item: "Narrative preset", cell: "BN6" },
{ key: "narrative", item: "Explanation of Review Findings", cell: "C60" },
{ key: "processingDistrict1", item: "Processing District 1st digit", cell: "C56" },
{ key: "processingDistrict2", item: "Processing District 2nd digit", cell: "D56" },
{ key: "homeCountyArea", item: "Home County - Area", cell: "E56" },
{ key: "homeCountyDigit1", item: "Home County - 1st digit", cell: "F56" },
{ key: "homeCountyDigit2", item: "Home County - 2nd digit", cell: "G56" },
{ key: "homeCountyDistrict1", item: "Home County - 1st digit of district", cell: "H56" },
{ key: "homeCountyDistrict2", item: "Home County - 2nd digit of district", cell: "I56" },
{ key: "fs75", item: "FS 75?", cell: "J56" },
{ key: "rejectionReasonEsap", item: "Rejection Reasons for ESAP", cell: "K56" },
{ key: "workerExperience", item: "Worker Experience", cell: "L56" },
];
export const reviewFieldByKey = Object.fromEntries(
reviewFieldDefinitions.map((definition) => [definition.key, definition]),
) as Record<keyof ReviewAnswers, FieldDefinition | undefined>;
const actionTypeChoices: FieldChoice[] = [
{ value: "1", label: "Denial of SNAP application" },
{ value: "2", label: "Termination of SNAP benefits" },
{ value: "3", label: "Suspension of SNAP benefits" },
];
const caseClassificationChoices: FieldChoice[] = [
{ value: "1", label: "Included in error rate calculation" },
{ value: "2", label: "Excluded — processed by SSA worker" },
{ value: "3", label: "Excluded — designated by FNS" },
];
const dispositionChoices: FieldChoice[] = [
{ value: "1", label: "Review completed" },
{ value: "2", label: "Not Subject to Review / Listed in Error" },
{ value: "3", label: "Incomplete / Review Not Processed" },
{ value: "4", label: "Case deselected / oversampling correction" },
];
const nstrReasonChoices: FieldChoice[] = [
{ value: "01", label: "Withdrawn application" },
{ value: "02", label: "Active IPV investigation" },
{ value: "03", label: "IPV investigation scheduled in next 5 months" },
{ value: "04", label: "Pending IPV hearing" },
{ value: "05", label: "Certification-period closure, not recertified" },
{ value: "06", label: "Oversampling correction" },
{ value: "07", label: "Pending notice, not actually denied" },
{ value: "08", label: "Listed in error / admin correction" },
{ value: "09", label: "Disaster certification" },
{ value: "10", label: "Monthly report late then reinstated" },
{ value: "11", label: "Computer malfunction (needs FNS approval)" },
{ value: "12", label: "Suspended after first month of multi-month suspension" },
];
const findingChoices: FieldChoice[] = [
{ value: "1", label: "Valid (all three components correct)" },
{ value: "2", label: "Invalid (one or more incorrect)" },
];
const denialReasonChoices: FieldChoice[] = [
{ value: "01", label: "Institution not FNS-authorized" },
{ value: "02", label: "Outside project area or State" },
{ value: "03", label: "Ineligible striker" },
{ value: "04", label: "Ineligible noncitizen" },
{ value: "05", label: "Ineligible student" },
{ value: "06", label: "Ineligible boarder" },
{ value: "07", label: "Missed interview(s)" },
{ value: "08", label: "Failed to provide verification" },
{ value: "09", label: "Refusal to cooperate" },
{ value: "10", label: "Refusal to supply SSN" },
{ value: "11", label: "Over gross income" },
{ value: "12", label: "Over net income" },
{ value: "13", label: "Exceeds resource standard" },
{ value: "14", label: "Transfer of resources" },
{ value: "15", label: "Work registration / job search" },
{ value: "16", label: "Voluntary quit" },
{ value: "17", label: "Failure to submit / complete report" },
{ value: "18", label: "Voluntary withdrawal after certification" },
{ value: "19", label: "TANF termination / denial" },
{ value: "20", label: "IPV" },
{ value: "21", label: "Program disqualification" },
{ value: "22", label: "ABAWD time limit expired" },
{ value: "23", label: "SNAP work requirements" },
{ value: "24", label: "Eligible for zero benefits" },
{ value: "25", label: "Failure to access EBT" },
{ value: "26", label: "Loss of contact" },
{ value: "27", label: "Deceased" },
{ value: "28", label: "Not eligible for separate household" },
{ value: "29", label: "Fleeing felon / parole / drug conviction" },
{ value: "30", label: "Reason not documented" },
{ value: "31", label: "Benefits in another SNAP HH same period" },
{ value: "32", label: "Benefits in another state same period" },
{ value: "33", label: "Tribal commodities" },
{ value: "99", label: "Other" },
{ value: "00", label: "Cannot be determined" },
];
const recordedReasonAccuracyChoices: FieldChoice[] = [
{ value: "1", label: "Accurate — record supports the reason" },
{ value: "2", label: "Inaccurate — record does not support the reason" },
{ value: "3", label: "Insufficient information" },
];
const noticeRequirementChoices: FieldChoice[] = [
{ value: "1", label: "Notice required and sent" },
{ value: "2", label: "Required and not sent" },
{ value: "3", label: "No requirement, and did send" },
{ value: "4", label: "No requirement, and did not send" },
];
const noticeLanguageChoices: FieldChoice[] = [
{ value: "1", label: "All required language / information included" },
{ value: "2", label: "Not included" },
{ value: "3", label: "No notice sent" },
];
const noticeClearChoices: FieldChoice[] = [
{ value: "1", label: "Yes — specific, clear, understandable" },
{ value: "2", label: "No on any of those three — Section V required" },
{ value: "3", label: "No notice sent" },
];
const noticeReasonToHhChoices: FieldChoice[] = [
...denialReasonChoices.filter((choice) => choice.value !== "00" && choice.value !== "99"),
{ value: "00", label: "No notice sent" },
];
const noticeReasonAccuracyChoices: FieldChoice[] = [
{ value: "11", label: "Accurate, matches recorded reason" },
{ value: "12", label: "Accurate, does not match" },
{ value: "21", label: "Inaccurate, matches" },
{ value: "22", label: "Inaccurate, does not match" },
{ value: "99", label: "No notice sent" },
];
const proceduralChoices: FieldChoice[] = [
{ value: "1", label: "Required and completed correctly" },
{ value: "2", label: "Required and not completed correctly" },
{ value: "3", label: "Not required" },
];
const periodicReportChoices: FieldChoice[] = [
{ value: "1", label: "Required and sent" },
{ value: "2", label: "Required and not sent" },
{ value: "3", label: "Not required" },
];
const actionTimelinessChoices: FieldChoice[] = [
{ value: "1", label: "Action taken timely" },
{ value: "2", label: "Too early" },
{ value: "3", label: "Late" },
];
const noticeTimelinessChoices: FieldChoice[] = [
{ value: "1", label: "Notice sent timely" },
{ value: "2", label: "Late" },
{ value: "3", label: "No notice sent" },
];
const contactResponseChoices: FieldChoice[] = [
{ value: "01", label: "No collateral or HH contact" },
{ value: "02", label: "Phone to collateral" },
{ value: "03", label: "Phone to household" },
{ value: "04", label: "Letter to collateral" },
{ value: "05", label: "Letter to household" },
{ value: "06", label: "Face-to-face to collateral" },
{ value: "07", label: "Face-to-face to household" },
];
const actionInitiatedByChoices: FieldChoice[] = [
{ value: "01", label: "HH reported" },
{ value: "02", label: "Automated source" },
{ value: "03", label: "Other source" },
{ value: "04", label: "Application" },
{ value: "05", label: "Interim report completed" },
{ value: "06", label: "Interim report not submitted" },
{ value: "07", label: "Failure to provide info from application" },
{ value: "08", label: "Failure to provide info from interim report" },
{ value: "09", label: "Recertification submitted" },
{ value: "10", label: "Failure to provide info from recertification" },
{ value: "11", label: "Other" },
];
const narrativePresetChoices: FieldChoice[] = [
{ value: "1", label: "SAR Suspended Philly" },
{ value: "2", label: "SAR terminated NOT Philly" },
{ value: "3", label: "Rejection 047 EX FS Issued" },
{ value: "4", label: "Rejection 047 EX FS Denied" },
{ value: "5", label: "Rejection 042 EX FS Issued" },
{ value: "6", label: "Rejection 042 EX FS Denied" },
];
const fs75Choices: FieldChoice[] = [
{ value: "1", label: "Yes" },
{ value: "2", label: "No" },
{ value: "3", label: "N/A" },
];
const rejectionReasonEsapChoices: FieldChoice[] = [
{ value: "1", label: "Incomplete Application" },
{ value: "2", label: "Failure to Provide Verification" },
{ value: "3", label: "Income" },
{ value: "4", label: "Fleeing Felon" },
{ value: "5", label: "Drug Felony" },
{ value: "6", label: "Resources" },
{ value: "7", label: "Client Request" },
{ value: "8", label: "Alien Status" },
{ value: "9", label: "ESAP Ineligible" },
];
const workerExperienceChoices: FieldChoice[] = [
{ value: "1", label: "IMCW less than one year" },
{ value: "2", label: "1 to 5 years" },
{ value: "3", label: "Greater than 5 years" },
{ value: "4", label: "Cannot be determined" },
{ value: "5", label: "Automated Action" },
];
export const elementCodeChoices: FieldChoice[] = [
{ value: "111", label: "Student Status" },
{ value: "130", label: "Citizenship and Non-Citizen Status" },
{ value: "140", label: "Residency" },
{ value: "150", label: "Household Composition" },
{ value: "151", label: "Recipient Disqualification" },
{ value: "160", label: "Employment & Training Programs" },
{ value: "161", label: "Time-limited participation" },
{ value: "162", label: "Work Registration Requirements" },
{ value: "163", label: "Voluntary Quit / Reduced Work Effort" },
{ value: "164", label: "Workfare and Comparable Workfare" },
{ value: "165", label: "Employment Status / Job Availability" },
{ value: "166", label: "Acceptance of Employment" },
{ value: "170", label: "Social Security Number" },
{ value: "211", label: "Bank Accounts or Cash on Hand" },
{ value: "212", label: "Nonrecurring Lump-sum Payment" },
{ value: "213", label: "Other Liquid Assets" },
{ value: "221", label: "Real Property" },
{ value: "222", label: "Vehicles" },
{ value: "224", label: "Other Non-Liquid Resources" },
{ value: "225", label: "Combined Resources" },
{ value: "311", label: "Wages and Salaries" },
{ value: "312", label: "Self-Employment" },
{ value: "313", label: "Other Earned Income" },
{ value: "321", label: "Earned Income Deductions" },
{ value: "323", label: "Dependent Care Deduction" },
{ value: "331", label: "RSDI" },
{ value: "332", label: "Veterans Benefits" },
{ value: "333", label: "SSI and/or State SSI Supplement" },
{ value: "334", label: "Unemployment Compensation" },
{ value: "335", label: "Worker's Compensation" },
{ value: "336", label: "Other Government Benefits" },
{ value: "342", label: "Contributions" },
{ value: "343", label: "Deemed Income" },
{ value: "344", label: "TANF, PA, or GA" },
{ value: "345", label: "Educational Grants / Scholarships / Loans" },
{ value: "346", label: "Other Unearned Income" },
{ value: "350", label: "Child Support Payments Received" },
{ value: "361", label: "Standard Deduction" },
{ value: "363", label: "Shelter Deduction" },
{ value: "364", label: "Standard Utility Allowance" },
{ value: "365", label: "Medical Deductions" },
{ value: "366", label: "Child Support Payment Deduction" },
{ value: "371", label: "Combined Gross Income" },
{ value: "372", label: "Combined Net Income" },
{ value: "412", label: "Budgeting System" },
{ value: "413", label: "Application" },
{ value: "414", label: "Joint TANF/FS Processing and Reporting" },
{ value: "415", label: "Verification" },
{ value: "416", label: "Action Type" },
{ value: "511", label: "Other" },
{ value: "520", label: "Arithmetic Computation" },
{ value: "530", label: "Transitional Benefits" },
{ value: "540", label: "Notices" },
];
/** Complete current workbook BF:BH lookup, retaining element-specific explanations. */
const natureCodeChoices: FieldChoice[] = [
...new Map(
natureCatalog.map((row) => [row.nature, { value: row.nature, label: row.label }]),
).values(),
];
export function natureChoicesForElement(element: string): FieldChoice[] {
return natureCatalog
.filter((row) => row.element === element)
.map((row) => ({ value: row.nature, label: row.label }));
}
export const codedFieldChoices: Partial<Record<keyof ReviewAnswers, FieldChoice[]>> = {
actionType: actionTypeChoices,
caseClassification: caseClassificationChoices,
disposition: dispositionChoices,
nstrReason: nstrReasonChoices,
finding: findingChoices,
recordedReason: denialReasonChoices,
recordedReasonAccuracy: recordedReasonAccuracyChoices,
noticeRequirement: noticeRequirementChoices,
noticeLanguage: noticeLanguageChoices,
noticeClearUnderstandable: noticeClearChoices,
noticeReasonToHh: noticeReasonToHhChoices,
noticeReasonAccuracy: noticeReasonAccuracyChoices,
missedInterviewNotice: proceduralChoices,
contactRequest: proceduralChoices,
verificationRequest: proceduralChoices,
periodicReport: periodicReportChoices,
actionTimeliness: actionTimelinessChoices,
noticeTimeliness: noticeTimelinessChoices,
contactResponse: contactResponseChoices,
actionInitiatedBy: actionInitiatedByChoices,
narrativePreset: narrativePresetChoices,
fs75: fs75Choices,
rejectionReasonEsap: rejectionReasonEsapChoices,
workerExperience: workerExperienceChoices,
};
const completedReviewFields: (keyof ReviewAnswers)[] = [
"actionType",
"caseClassification",
"finding",
"recordedReason",
"recordedReasonAccuracy",
"noticeRequirement",
"noticeLanguage",
"noticeClearUnderstandable",
"noticeReasonToHh",
"noticeReasonAccuracy",
"missedInterviewNotice",
"contactRequest",
"verificationRequest",
"periodicReport",
"actionTimeliness",
"noticeTimeliness",
"contactResponse",
"actionInitiatedBy",
];
const defaultAnswers = (): ReviewAnswers => ({
telephone: "",
actualAddress: "",
dateAssigned: "",
dateCompleted: "",
dateCleared: "",
stratum: "",
actionType: "",
caseClassification: "1",
disposition: "",
nstrReason: "",
finding: "",
recordedReason: "",
recordedReasonAccuracy: "",
noticeRequirement: "",
noticeLanguage: "",
noticeClearUnderstandable: "",
noticeReasonToHh: "",
noticeReasonAccuracy: "",
missedInterviewNotice: "",
contactRequest: "",
verificationRequest: "",
periodicReport: "",
actionTimeliness: "",
daysActionEarlyLate: "",
noticeTimeliness: "",
daysNoticeLate: "",
contactResponse: "",
actionInitiatedBy: "",
narrative: "",
narrativePreset: "",
variances: [],
processingDistrict1: "",
processingDistrict2: "",
homeCountyArea: "",
homeCountyDigit1: "",
homeCountyDigit2: "",
homeCountyDistrict1: "",
homeCountyDistrict2: "",
fs75: "",
rejectionReasonEsap: "",
workerExperience: "",
});
/** Fill missing answer keys so legacy snapshots never crash validation. */
export function normalizeReviewAnswers(raw: Partial<ReviewAnswers> | undefined): ReviewAnswers {
return { ...defaultAnswers(), ...raw, variances: raw?.variances ?? [] };
}
export function fieldLabel(key: keyof ReviewAnswers): string {
return reviewFieldByKey[key]?.item ?? key;
}
export function isValidChoice(key: keyof ReviewAnswers, value: string): boolean {
if (!value.trim()) return false;
const choices = codedFieldChoices[key];
if (!choices) return true;
return choices.some((choice) => choice.value === value);
}
function isValidDays(value: string): boolean {
if (!value.trim()) return false;
if (value === "99") return true;
return /^\d+$/.test(value) && Number(value) >= 0 && Number(value) <= 98;
}
function isValidVarianceElement(value: string): boolean {
return !value.trim() || elementCodeChoices.some((choice) => choice.value === value);
}
function isValidVarianceNature(value: string): boolean {
return !value.trim() || natureCodeChoices.some((choice) => choice.value === value);
}
/** Fields required for submit given the current disposition and timeliness answers. */
export function requiredFieldsForAnswers(answers: ReviewAnswers): (keyof ReviewAnswers)[] {
const required: (keyof ReviewAnswers)[] = ["disposition"];
if (!answers.disposition?.trim()) return required;
if (answers.disposition === "2") {
required.push("nstrReason");
return required;
}
if (answers.disposition === "3" || answers.disposition === "4") return required;
if (answers.disposition === "1") {
required.push(...completedReviewFields);
if (answers.actionTimeliness === "2" || answers.actionTimeliness === "3") {
required.push("daysActionEarlyLate");
}
if (answers.noticeTimeliness === "2") required.push("daysNoticeLate");
}
return required;
}
export function validateRequiredField(
key: keyof ReviewAnswers,
answers: ReviewAnswers,
validateFormats = true,
): string | undefined {
const value = String(answers[key] ?? "").trim();
const required = requiredFieldsForAnswers(answers).includes(key);
if (required && !value) return `${fieldLabel(key)} is required.`;
if (!value) return undefined;
if (key === "daysActionEarlyLate" || key === "daysNoticeLate") {
if (!isValidDays(value)) return `${fieldLabel(key)} must be 0–98 or 99 for 99+ days.`;
return undefined;
}
if (
validateFormats &&
["dateAssigned", "dateCompleted", "dateCleared"].includes(key) &&
!isCalendarDate(value)
)
return `${fieldLabel(key)} must be a valid calendar date (YYYY-MM-DD).`;
if (
validateFormats &&
[
"processingDistrict1",
"processingDistrict2",
"homeCountyDigit1",
"homeCountyDigit2",
"homeCountyDistrict1",
"homeCountyDistrict2",
].includes(key) &&
!/^\d$/.test(value)
)
return `${fieldLabel(key)} must be one digit (0–9).`;
if (key === "variances") return undefined;
if (!isValidChoice(key, value)) return `${fieldLabel(key)} has an invalid coded value.`;
return undefined;
}
export function validateVariancePairs(
variances: ReviewAnswers["variances"],
): { index: number; field: "element" | "nature"; message: string }[] {
const issues: { index: number; field: "element" | "nature"; message: string }[] = [];
for (const [index, variance] of variances.entries()) {
const hasElement = variance.element.trim().length > 0;
const hasNature = variance.nature.trim().length > 0;
if (hasElement && !isValidVarianceElement(variance.element)) {
issues.push({ index, field: "element", message: "Element code is invalid." });
}
if (
hasNature &&
(!isValidVarianceNature(variance.nature) ||
(hasElement &&
!natureChoicesForElement(variance.element).some(
(choice) => choice.value === variance.nature,
)))
) {
issues.push({ index, field: "nature", message: "Nature code is invalid." });
}
if (hasElement !== hasNature) {
issues.push({
index,
field: hasElement ? "nature" : "element",
message: "Element and nature must both be coded or both blank.",
});
}
}
return issues;
}