feat(review+990pf): Part XV application info — parse the preselected-only kill signal, surface how-to-apply at review

Parser captures 990-PF Part XV line 2 (OnlyContriToPreselectedInd) even
when the application group is absent. Synthesis skips preselected-only
funders and closes their previously-synthesized grant rows
(serverCloseGrantsForFunders). Match detail page gains a 'How to apply
(funder's own 990-PF, Part XV)' panel — application form, deadlines,
restrictions, red do-not-pitch banner — fed by serverGetMatchDetail.

Backfill re-parse across all 747 NH foundations: 192 are preselected-
only; 62 of 123 synthesized foundation leads were unpitchable by their
funder's own filing and are now closed, with 24,436 stale pending
matches cleaned. Post re-match: 714 easy wins across 327 orgs, every
foundation lead now backed by a funder that actually accepts
applications. 156 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Croissant Le Doux
2026-07-16 21:46:58 -04:00
parent d8ca133e95
commit a92b75b315
7 changed files with 166 additions and 3 deletions

View File

@@ -53,7 +53,15 @@ const SUBSCORE_MAX: Record<string, number> = {
export default function MatchDetail({ loaderData }: Route.ComponentProps) {
const { detail } = loaderData ?? {};
if (detail == null) return null;
const { match, org, grant, funderGivingHistory } = detail;
const { match, org, grant, funderGivingHistory, funderApplicationInfo } =
detail;
const applyInfo = (funderApplicationInfo ?? null) as {
preselectedOnly?: boolean;
recipientName?: string;
formAndInfoAndMaterials?: string;
submissionDeadlines?: string;
restrictionsOnAwards?: string;
} | null;
const subscores = (match.subscores ?? {}) as Record<string, number>;
const rationale = (match.rationale ?? {}) as Record<string, unknown>;
const nhHistory = funderGivingHistory;
@@ -233,6 +241,47 @@ export default function MatchDetail({ loaderData }: Route.ComponentProps) {
)}
</section>
{applyInfo != null && (
<section className="rounded border p-4">
<h2 className="mb-2 text-lg font-semibold">
How to apply (funder's own 990-PF, Part XV)
</h2>
{applyInfo.preselectedOnly === true && (
<p className="mb-2 rounded bg-red-100 p-2 text-sm font-medium text-red-800">
This funder states it only contributes to preselected
organizations and does not accept unsolicited requests — do
not pitch.
</p>
)}
<dl className="space-y-1 text-sm">
{applyInfo.formAndInfoAndMaterials != null && (
<div>
<dt className="inline font-medium">Application form: </dt>
<dd className="inline">{applyInfo.formAndInfoAndMaterials}</dd>
</div>
)}
{applyInfo.submissionDeadlines != null && (
<div>
<dt className="inline font-medium">Deadlines: </dt>
<dd className="inline">{applyInfo.submissionDeadlines}</dd>
</div>
)}
{applyInfo.restrictionsOnAwards != null && (
<div>
<dt className="inline font-medium">Restrictions: </dt>
<dd className="inline">{applyInfo.restrictionsOnAwards}</dd>
</div>
)}
{applyInfo.recipientName != null && (
<div>
<dt className="inline font-medium">Applications to: </dt>
<dd className="inline">{applyInfo.recipientName}</dd>
</div>
)}
</dl>
</section>
)}
<section className="rounded border p-4">
<h2 className="mb-2 text-lg font-semibold">Grant synopsis</h2>
<p className="whitespace-pre-wrap text-sm">{grant.synopsis ?? ''}</p>

View File

@@ -151,3 +151,31 @@ describe('parse990PfXml', () => {
});
});
});
describe('preselected-only indicator', () => {
const wrap = (inner: string) => `<?xml version="1.0"?>
<Return xmlns="http://www.irs.gov/efile">
<ReturnHeader><TaxYr>2024</TaxYr></ReturnHeader>
<ReturnData><IRS990PF><SupplementaryInformationGrp>${inner}</SupplementaryInformationGrp></IRS990PF></ReturnData>
</Return>`;
it('captures the checkbox even when the application group is absent', () => {
const parsed = parse990PfXml(wrap('<OnlyContriToPreselectedInd>X</OnlyContriToPreselectedInd>'));
expect(parsed.applicationInfo).toEqual({ preselectedOnly: true });
});
it('flags it alongside application fields when both exist', () => {
const parsed = parse990PfXml(wrap(`
<OnlyContriToPreselectedInd>X</OnlyContriToPreselectedInd>
<ApplicationSubmissionInfoGrp><SubmissionDeadlinesTxt>NONE</SubmissionDeadlinesTxt></ApplicationSubmissionInfoGrp>`));
expect(parsed.applicationInfo).toMatchObject({
preselectedOnly: true,
submissionDeadlines: 'NONE',
});
});
it('stays absent when unchecked', () => {
const parsed = parse990PfXml(wrap('<ApplicationSubmissionInfoGrp><SubmissionDeadlinesTxt>ROLLING</SubmissionDeadlinesTxt></ApplicationSubmissionInfoGrp>'));
expect((parsed.applicationInfo as Record<string, unknown>)?.preselectedOnly).toBeUndefined();
});
});

View File

@@ -122,9 +122,26 @@ function extractApplicationInfo(
Record<string, unknown>[] | Record<string, unknown> | undefined);
const groups = coerceArray<Record<string, unknown>>(raw);
const first = groups[0];
if (first == null) return null;
// Part XV line 2 checkbox: "only makes contributions to preselected
// charitable organizations and does not accept unsolicited requests" —
// the kill signal for outreach. Checked filings usually OMIT the
// application-submission group entirely, so read it from the
// supplementary group (or form root) whether or not `first` exists.
const preselectedRaw =
suppInfo?.OnlyContriToPreselectedInd ?? pf?.OnlyContriToPreselectedInd;
const preselectedOnly =
preselectedRaw != null &&
/^(x|1|true)$/i.test(
String(textOf(preselectedRaw) ?? preselectedRaw).trim(),
);
if (first == null) {
return preselectedOnly ? { preselectedOnly: true } : null;
}
const result: Record<string, unknown> = {};
if (preselectedOnly) result.preselectedOnly = true;
const recipientName = textOf(first.RecipientNm);
if (recipientName != null) result.recipientName = recipientName;
const formAndInfo = textOf(first.FormAndInfoAndMaterialsTxt);

View File

@@ -49,6 +49,7 @@ import type { schema } from '@novelpad/outreach-core';
import {
serverInsertGrants,
serverListFunderLatestObjectIds,
serverCloseGrantsForFunders,
serverListFunderSynthesisData,
serverReplaceFunderGrantsForYear,
serverUpsertFunder,
@@ -462,6 +463,14 @@ export function buildSynthesizedGrant(
};
}
function isPreselectedOnly(applicationInfo: unknown): boolean {
return (
applicationInfo != null &&
typeof applicationInfo === 'object' &&
(applicationInfo as { preselectedOnly?: unknown }).preselectedOnly === true
);
}
async function synthesizeFunderGrants(db: OutreachDb): Promise<number> {
const rows = await serverListFunderSynthesisData(db, {
recipientState: RECIPIENT_STATE,
@@ -469,8 +478,26 @@ async function synthesizeFunderGrants(db: OutreachDb): Promise<number> {
});
if (rows.length === 0) return 0;
// Part XV "preselected organizations only" filers don't accept
// unsolicited requests — synthesizing a lead from them would produce an
// email their own filing says is unwanted. Skip them, and close any
// grant rows synthesized before the indicator was parsed.
const preselected = rows.filter((row) => isPreselectedOnly(row.applicationInfo));
if (preselected.length > 0) {
const closed = await serverCloseGrantsForFunders(
db,
preselected.map((row) => row.ein),
);
console.log(
`[ingest-990pf] preselected-only funders skipped: ${preselected.length} (${closed} previously-synthesized grant rows closed)`,
);
}
const pitchable = rows.filter((row) => !isPreselectedOnly(row.applicationInfo));
if (pitchable.length === 0) return 0;
const now = new Date();
const grants = rows.map((row) => buildSynthesizedGrant(row, now));
const grants = pitchable.map((row) => buildSynthesizedGrant(row, now));
await serverInsertGrants(db, grants);
return grants.length;
}