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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user