src/programs/snap-negative/calculation.ts
Linked repository file from this build.
import { MAX_MONEY_CENTS, MAX_INCOME_ROWS } from "./input-limits";
import {
assertCalcVersion,
CURRENT_CALC_VERSION,
EDUCATION_CALC_VERSION,
LEGACY_CALC_VERSION,
} from "./rule-versions";
import {
demoPolicy,
expandedDemoPolicy,
initialPolicy,
resolveReviewPolicy,
type SnapPolicy,
} from "./policy";
import type { ComputationResult, FinalResult, IncomeStream, Scenario, SnapReview } from "./types";
import { RuleError } from "@/lib/errors";
const dollars = (value: number) => value * 100;
const roundDollar = (cents: number) => Math.round(cents / 100) * 100;
/** Pure ordinary monthly calculation. Unsupported facts fail explicitly. */
export function calculate(
scenario: Scenario,
sampleMonth: string,
calcVersion = CURRENT_CALC_VERSION,
boundPolicy?: SnapPolicy,
): ComputationResult {
assertCalcVersion(calcVersion);
const supportsSelfEmployment = calcVersion === CURRENT_CALC_VERSION;
const supportsEducation = supportsSelfEmployment || calcVersion === EDUCATION_CALC_VERSION;
const grant = scenario.educationGrantCents ?? 0;
const educationExpenses = scenario.educationExpensesCents ?? 0;
const applicationDay = scenario.applicationDay ?? null;
if (!supportsEducation && (grant !== 0 || educationExpenses !== 0 || applicationDay !== null))
throw new RuleError(
"Education and proration require v3. Ask admin or statistics to apply new rules.",
);
if (
applicationDay !== null &&
(!Number.isInteger(applicationDay) || applicationDay < 1 || applicationDay > 31)
)
throw new RuleError("Application day must be blank or a whole number from 1 through 31.");
const policy =
boundPolicy ??
(supportsSelfEmployment
? initialPolicy
: calcVersion === LEGACY_CALC_VERSION
? demoPolicy
: expandedDemoPolicy);
if (
!/^\d{4}-(0[1-9]|1[0-2])$/.test(sampleMonth) ||
sampleMonth < policy.effectiveFrom ||
(!boundPolicy && !supportsSelfEmployment && sampleMonth > expandedDemoPolicy.effectiveThrough)
)
throw new RuleError("No verified demo policy snapshot for this sample month.");
if (!scenario.populated) throw new RuleError("Complete at least one examiner computation.");
if (
!Number.isInteger(scenario.householdSize) ||
scenario.householdSize < 1 ||
scenario.householdSize > (calcVersion === LEGACY_CALC_VERSION ? 8 : policy.allotments.length)
)
throw new RuleError(
calcVersion === LEGACY_CALC_VERSION
? "This review uses v1 rules (sizes 1–8). Ask admin or statistics to apply current rules for sizes 1–20."
: "Household size must be a whole number from 1 through 20.",
);
if (scenario.income.length > MAX_INCOME_ROWS)
throw new RuleError(`A scenario supports up to ${MAX_INCOME_ROWS} income rows.`);
const money = [
grant,
educationExpenses,
scenario.medicalDeductionCents,
scenario.dependentCareCents,
scenario.childSupportCents,
scenario.rentCents,
scenario.taxesInsuranceCents,
scenario.installationCents,
scenario.otherShelterCents,
...scenario.income.flatMap((i) => [i.amountCents, i.expensesCents]),
];
if (money.some((v) => !Number.isSafeInteger(v) || v < 0 || v > MAX_MONEY_CENTS))
throw new RuleError("Enter valid non-negative money amounts, up to $1,000,000.");
if (!(scenario.sua in policy.sua)) throw new RuleError("Select a supported SUA type.");
if (
!supportsSelfEmployment &&
scenario.income.some((i) => i.category === "self_employment" && i.expensesCents > i.amountCents)
)
throw new RuleError(
"Self-employment losses need verification; this demo supports net profits only.",
);
if (scenario.income.some((i) => !["earned", "unearned", "self_employment"].includes(i.category)))
throw new RuleError("Select a valid income category.");
const idx = scenario.householdSize - 1;
const se = selfEmploymentTotals(scenario.income, boundPolicy ?? initialPolicy);
const earnedCents = supportsSelfEmployment
? scenario.income
.filter((i) => i.category === "earned")
.reduce((sum, i) => sum + i.amountCents, 0) + se.profitCents
: scenario.income
.filter((i) => i.category !== "unearned")
.reduce(
(sum, i) =>
sum + i.amountCents - (i.category === "self_employment" ? i.expensesCents : 0),
0,
);
const selfEmploymentLossCents = supportsSelfEmployment ? se.lossCents : 0;
const unearnedCents = roundDollar(
scenario.income
.filter((i) => i.category === "unearned")
.reduce((sum, i) => sum + i.amountCents, 0),
);
const educationNetCents = Math.max(grant - educationExpenses, 0);
const earnedAndEducationCents = earnedCents + educationNetCents;
const grossCents = roundDollar(
roundDollar(earnedAndEducationCents + unearnedCents) - selfEmploymentLossCents,
);
const grossLimitCents = dollars(policy.grossLimits[idx]);
const overGross =
grossCents > grossLimitCents && !scenario.elderlyDisabled && !scenario.categoricalEligibility;
const earnedDeductionCents = overGross
? 0
: roundDollar((earnedCents * policy.earnedDeductionPercent) / 100);
const standardDeductionCents = overGross ? 0 : dollars(policy.standardDeductions[idx]);
const adjustedIncomeCents = overGross
? 0
: Math.max(
0,
grossCents -
earnedDeductionCents -
standardDeductionCents -
scenario.medicalDeductionCents -
scenario.dependentCareCents -
scenario.childSupportCents,
);
const suaCents = dollars(policy.sua[scenario.sua]);
const shelterCents =
scenario.rentCents +
scenario.taxesInsuranceCents +
scenario.installationCents +
scenario.otherShelterCents +
suaCents;
const excess = Math.max(
0,
shelterCents -
roundDollar((adjustedIncomeCents * (boundPolicy?.shelterDeductionPercent ?? 50)) / 100),
);
const shelterDeductionCents = overGross
? 0
: scenario.elderlyDisabled
? excess
: Math.min(excess, dollars(policy.shelterCap));
const netCents = Math.max(
0,
adjustedIncomeCents -
shelterDeductionCents -
(scenario.homeless ? dollars(policy.homelessDeduction) : 0),
);
const netLimitCents = dollars(policy.netLimits[idx]);
const eligible = !overGross && (scenario.categoricalEligibility || netCents <= netLimitCents);
const allotmentCents = dollars(policy.allotments[idx]);
const contributionCents = Math.ceil(Math.floor(netCents / 100) * 0.3) * 100;
const minimum =
scenario.categoricalEligibility && scenario.householdSize <= 2
? dollars(policy.minimumBenefit)
: 0;
const monthlyBenefitCents = eligible
? Math.max(minimum, allotmentCents - contributionCents, 0)
: 0;
const benefitCents =
supportsEducation && applicationDay !== null
? prorateBenefit(monthlyBenefitCents, applicationDay)
: monthlyBenefitCents;
return {
policyId: policy.id,
...(supportsEducation
? { educationNetCents, earnedAndEducationCents, monthlyBenefitCents }
: {}),
householdSize: scenario.householdSize,
earnedCents,
...(supportsSelfEmployment
? { selfEmploymentProfitCents: se.profitCents, selfEmploymentLossCents }
: {}),
unearnedCents,
grossCents,
grossLimitCents,
earnedDeductionCents,
standardDeductionCents,
adjustedIncomeCents,
suaCents,
shelterCents,
shelterDeductionCents,
netCents,
netLimitCents,
allotmentCents,
benefitCents,
eligible,
};
}
/** Workbook's 30-day month, rounded down to whole dollars. */
export function prorateBenefit(monthlyBenefitCents: number, day: number | null): number {
if (day === null) return monthlyBenefitCents;
if (!Number.isInteger(day) || day < 1 || day > 31)
throw new RuleError("Application day must be blank or 1–31.");
if (monthlyBenefitCents <= 1000) return 0;
return Math.floor(((31 - day) * monthlyBenefitCents) / 3000) * 100;
}
export function combineIncome(income: IncomeStream[]): IncomeStream[] {
const combined = new Map<string, IncomeStream>();
for (const entry of income) {
const key = `${entry.memberId}/${entry.typeCode}/${entry.selfEmploymentKind ?? "non_farm"}`;
const prior = combined.get(key);
if (prior && prior.category !== entry.category)
throw new RuleError("Matching member/type income entries must use the same category.");
combined.set(
key,
prior
? {
...prior,
amountCents: prior.amountCents + entry.amountCents,
expensesCents: prior.expensesCents + entry.expensesCents,
}
: { ...entry },
);
}
return [...combined.values()];
}
export function selectFinal(review: SnapReview): FinalResult {
const { facts } = review;
const populated = Object.values(facts.scenarios).filter((s) => s.populated);
for (const scenario of populated) {
if (
scenario.income.some(
(i) => !facts.members.some((m) => m.id === i.memberId) || !i.typeCode.trim(),
)
)
throw new RuleError("Every income stream needs a household member and income-source type.");
}
const cao = calculate(
facts.scenarios.eligibility,
review.sampleMonth,
review.calcVersion,
review.policy || review.policyId ? resolveReviewPolicy(review) : undefined,
);
const first = facts.scenarios.compI.populated
? calculate(
facts.scenarios.compI,
review.sampleMonth,
review.calcVersion,
review.policy || review.policyId ? resolveReviewPolicy(review) : undefined,
)
: undefined;
const second = facts.scenarios.compII.populated
? calculate(
facts.scenarios.compII,
review.sampleMonth,
review.calcVersion,
review.policy || review.policyId ? resolveReviewPolicy(review) : undefined,
)
: undefined;
if (!first && !second) throw new RuleError("Complete at least one examiner computation.");
const useSecond =
!!second &&
(!first ||
Math.abs(cao.benefitCents - second.benefitCents) <
Math.abs(cao.benefitCents - first.benefitCents));
const scenario = useSecond ? "compII" : "compI";
const result = (useSecond ? second : first)!;
const reason =
!first || !second
? `${useSecond ? "Comp II" : "Comp I"} selected as the only completed examiner computation.`
: useSecond
? "Comp II selected because its absolute error is smaller than Comp I."
: Math.abs(cao.benefitCents - first.benefitCents) ===
Math.abs(cao.benefitCents - second.benefitCents)
? "Comp I selected because absolute errors tie."
: "Comp I selected because its absolute error is smaller than Comp II.";
return {
scenario,
reason,
result,
errorCents: cao.benefitCents - result.benefitCents,
income: combineIncome(facts.scenarios[scenario].income),
inputs: structuredClone(facts.scenarios[scenario]),
inputsRevision: facts.inputsRevision,
};
}
/** AF8:AF19: farm gain/loss, plus non-farm net, split into line 1 profit and line 8 loss. */
export function selfEmploymentTotals(income: IncomeStream[], policy: SnapPolicy = initialPolicy) {
let farmGrossCents = 0,
farmCostsCents = 0,
nonFarmGrossCents = 0,
nonFarmExpensesCents = 0;
for (const row of income) {
if (row.category !== "self_employment") continue;
if (row.selfEmploymentKind === "farm") {
farmGrossCents += row.amountCents;
farmCostsCents += Math.round((row.expensesCents * policy.farmExpenseAllowancePercent) / 100);
} else {
nonFarmGrossCents += row.amountCents;
nonFarmExpensesCents += Math.round(
(row.expensesCents * policy.nonFarmExpenseAllowancePercent) / 100,
);
}
}
const farmNetCents = farmGrossCents - farmCostsCents;
const farmGainCents = Math.max(farmNetCents, 0);
const farmLossCents = Math.max(-farmNetCents, 0);
const netBeforeTaxesCents = nonFarmGrossCents + farmGainCents - nonFarmExpensesCents;
const netCents = netBeforeTaxesCents - farmLossCents;
return {
farmGrossCents,
farmCostsCents,
farmGainCents,
farmLossCents,
nonFarmGrossCents,
nonFarmExpensesCents,
netBeforeTaxesCents,
netCents,
profitCents: Math.max(netCents, 0),
lossCents: Math.max(-netCents, 0),
};
}