← back to Ca Donations
scripts/ingest-990-grants.mjs
299 lines
// Ingest CA private-foundation GRANT records (990-PF Part XV outbound grants) into
// charitable_grants. Also captures 990 Schedule I / Schedule F grant tables when present.
//
// SOURCE PATH (verified 2026-08-21):
// - The AWS s3://irs-form-990 HTTPS mirror is DEAD (404) and ProPublica blocks bulk XML.
// - The working free path is the IRS canonical e-file XML: the annual TEOS index CSV
// (https://apps.irs.gov/pub/epostcard/990/xml/2024/index_2024.csv) maps every filing to
// an OBJECT_ID + XML_BATCH_ID (the zip that contains <OBJECT_ID>_public.xml). We download
// only the batch zips that hold the most CA foundations, and extract ONLY the target XMLs.
//
// v1 BOUND (tractable): CA foundations (subsection 990-PF filers, joined from charitable_orgs)
// present in the 2024 index, restricted to the batch zips already downloaded into data/raw/,
// then capped to the TOP N (default 200) foundations by total grant dollars observed. The
// script logs covered vs skipped counts — no silent truncation.
//
// Grant parse:
// 990-PF Part XV -> SupplementaryInformationGrp/GrantOrContributionPdDurYrGrp
// { RecipientPersonNm | RecipientBusinessName/BusinessNameLine1Txt,
// RecipientUSAddress/{CityNm,StateAbbreviationCd},
// GrantOrContributionPurposeTxt, Amt } -> grant_type 990pf_partxv
// 990 Schedule I -> IRS990ScheduleI/RecipientTable -> grant_type 990_sched_i
// 990 Schedule F -> IRS990ScheduleF/GrantsToOrgOutsideUSGrp / ... -> grant_type 990_sched_f
//
// external_id = <object_id>:<grant_type>:<index> (stable, idempotent).
// $0 — IRS bulk + already-loaded ProPublica org list; no paid API.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import unzipper from 'unzipper';
import { XMLParser } from 'fast-xml-parser';
import { q, startRun, upsertSource, pool } from '../lib/db.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const RAW = path.join(__dirname, '..', 'data', 'raw');
const YEAR = process.env.YEAR || '2024';
const INDEX_CSV = path.join(RAW, `index_${YEAR}.csv`);
const SLUG = 'irs_990';
const TOP_N = process.env.TOP_N ? parseInt(process.env.TOP_N, 10) : 200;
const parser = new XMLParser({
ignoreAttributes: true,
parseTagValue: false, // keep amounts as strings; we Number() ourselves
trimValues: true,
});
function clean(s) {
if (s == null) return null;
const t = String(s).replace(/[\x00-\x1F\x7F]/g, '').trim();
return t === '' ? null : t;
}
function num(s) {
if (s == null || s === '') return null;
const n = Number(String(s).replace(/[$,]/g, ''));
return Number.isFinite(n) ? n : null;
}
function arr(x) { return x == null ? [] : Array.isArray(x) ? x : [x]; }
// Read CA foundation EINs (dashless) from the DB.
async function caFoundationEins() {
const rows = await q(`SELECT replace(ein,'-','') AS ein FROM charitable_orgs WHERE ein IS NOT NULL`);
return new Set(rows.map((r) => r.ein));
}
// Parse the 2024 index CSV -> per-EIN newest 990PF filing {object_id, batch, tax_period, name}.
function readIndexTargets(caEins) {
const text = fs.readFileSync(INDEX_CSV, 'utf8');
const lines = text.split(/\r?\n/); // handle CRLF
const header = lines[0].split(',');
const col = Object.fromEntries(header.map((h, i) => [h.trim(), i]));
const hasBatch = col['XML_BATCH_ID'] !== undefined;
const best = new Map();
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (!line) continue;
const c = line.split(',');
if (c[col['RETURN_TYPE']] !== '990PF') continue;
const ein = c[col['EIN']];
if (!caEins.has(ein)) continue;
const tp = c[col['TAX_PERIOD']];
const cur = best.get(ein);
if (!cur || tp > cur.tax_period) {
best.set(ein, {
ein,
object_id: c[col['OBJECT_ID']],
batch: hasBatch ? c[col['XML_BATCH_ID']].toLowerCase() : null,
tax_period: tp,
name: c[col['TAXPAYER_NAME']],
});
}
}
return best;
}
// Which batch zips are actually present in data/raw/?
function presentBatches() {
const set = new Set();
for (const f of fs.readdirSync(RAW)) {
const m = f.match(/^(\d{4}_TEOS_XML_\d{2}[A-Za-z])\.zip$/);
if (m) set.add(m[1].toLowerCase());
}
return set;
}
// Extract grant rows from one parsed 990-PF/990 return object.
function extractGrants(obj, objectId) {
const rd = obj?.Return?.ReturnData || {};
const hdr = obj?.Return?.ReturnHeader || {};
const filer = hdr.Filer || {};
const grantorEin = clean(filer.EIN);
const grantorName = clean(filer.BusinessName?.BusinessNameLine1Txt || filer.BusinessName?.BusinessNameLine1Txt);
const taxYear = clean(hdr.TaxYr) ? parseInt(clean(hdr.TaxYr), 10) : null;
const out = [];
// --- 990-PF Part XV outbound grants ---
const pf = rd.IRS990PF || {};
const supp = pf.SupplementaryInformationGrp || {};
const partxv = arr(supp.GrantOrContributionPdDurYrGrp);
partxv.forEach((g, i) => {
const grantee = clean(g.RecipientPersonNm)
|| clean(g.RecipientBusinessName?.BusinessNameLine1Txt);
const addr = g.RecipientUSAddress || g.RecipientForeignAddress || {};
out.push({
external_id: `${objectId}:990pf_partxv:${i}`,
grantor_ein: grantorEin, grantor_name: grantorName,
grantee_name: grantee,
grantee_city: clean(addr.CityNm),
grantee_state: clean(addr.StateAbbreviationCd || addr.CountryCd),
amount: num(g.Amt),
purpose: clean(g.GrantOrContributionPurposeTxt),
tax_year: taxYear,
grant_type: '990pf_partxv',
});
});
// --- 990 Schedule I domestic grants (public charities / DAF sponsors) ---
const schI = rd.IRS990ScheduleI || {};
arr(schI.RecipientTable).forEach((g, i) => {
const grantee = clean(g.RecipientBusinessName?.BusinessNameLine1Txt) || clean(g.RecipientPersonNm);
const addr = g.USAddress || {};
out.push({
external_id: `${objectId}:990_sched_i:${i}`,
grantor_ein: grantorEin, grantor_name: grantorName,
grantee_name: grantee,
grantee_city: clean(addr.CityNm),
grantee_state: clean(addr.StateAbbreviationCd),
amount: num(g.CashGrantAmt) ?? num(g.NonCashAssistanceAmt),
purpose: clean(g.PurposeOfGrantTxt),
tax_year: taxYear,
grant_type: '990_sched_i',
});
});
// --- 990 Schedule F foreign grants ---
const schF = rd.IRS990ScheduleF || {};
arr(schF.GrantsToOrgOutsideUSGrp).forEach((g, i) => {
out.push({
external_id: `${objectId}:990_sched_f:${i}`,
grantor_ein: grantorEin, grantor_name: grantorName,
grantee_name: clean(g.NameOfOrganizationTxt) || null,
grantee_city: clean(g.RegionTxt),
grantee_state: clean(g.RegionTxt),
amount: num(g.CashGrantAmt),
purpose: clean(g.PurposeOfGrantTxt),
tax_year: taxYear,
grant_type: '990_sched_f',
});
});
// keep only rows with a grantee name or an amount (drop empty artifacts)
return out.filter((g) => g.grantee_name || g.amount);
}
const COLS = [
'source_slug', 'external_id', 'grantor_ein', 'grantor_name', 'grantee_name',
'grantee_city', 'grantee_state', 'amount', 'purpose', 'tax_year', 'grant_type',
];
async function flush(rows) {
if (!rows.length) return 0;
const byKey = new Map();
for (const r of rows) byKey.set(r.external_id, r);
rows = [...byKey.values()];
const ph = rows.map((_, i) => {
const b = i * COLS.length;
return '(' + COLS.map((_, j) => `$${b + j + 1}`).join(',') + ')';
});
const vals = [];
for (const r of rows) vals.push(...COLS.map((c) => (c === 'source_slug' ? SLUG : r[c])));
await q(
`INSERT INTO charitable_grants (${COLS.join(',')}) VALUES ${ph.join(',')}
ON CONFLICT (source_slug, external_id) DO UPDATE SET
grantor_ein=EXCLUDED.grantor_ein, grantor_name=EXCLUDED.grantor_name,
grantee_name=EXCLUDED.grantee_name, grantee_city=EXCLUDED.grantee_city,
grantee_state=EXCLUDED.grantee_state, amount=EXCLUDED.amount,
purpose=EXCLUDED.purpose, tax_year=EXCLUDED.tax_year, grant_type=EXCLUDED.grant_type`,
vals,
);
return rows.length;
}
async function main() {
if (!fs.existsSync(INDEX_CSV)) {
console.error(`Missing ${INDEX_CSV}. Download index_${YEAR}.csv into data/raw/ first.`);
process.exit(1);
}
await upsertSource({
slug: SLUG,
name: 'IRS Form 990 e-file XML (990-PF Part XV + 990 Sched I/F grants)',
url: 'https://www.irs.gov/charities-non-profits/form-990-series-downloads',
jurisdiction: 'federal', family: 'charitable', granularity: 'grant', access: 'bulk',
notes: `CA private-foundation outbound grants, filing year ${YEAR}. Object XML from TEOS batch zips.`,
});
const run = await startRun(SLUG);
let foundationsCovered = 0, foundationsSkippedNoBatch = 0, grantsUpserted = 0;
try {
const caEins = await caFoundationEins();
const targets = readIndexTargets(caEins); // ein -> filing
const present = presentBatches();
console.log(`CA 990PF foundations in ${YEAR} index: ${targets.size}`);
console.log(`Batch zips present in data/raw/: ${[...present].join(', ') || '(none)'}`);
// Restrict to foundations whose batch zip we actually have.
const reachable = [];
for (const t of targets.values()) {
if (t.batch && present.has(t.batch)) reachable.push(t);
else foundationsSkippedNoBatch++;
}
console.log(`Reachable (batch present): ${reachable.length}; unreachable (batch not downloaded): ${foundationsSkippedNoBatch}`);
// Group reachable targets by batch so each zip is opened once.
const byBatch = new Map();
for (const t of reachable) {
if (!byBatch.has(t.batch)) byBatch.set(t.batch, new Map());
byBatch.get(t.batch).set(t.object_id, t);
}
// First pass: parse every reachable foundation's grants into memory, keyed by ein,
// so we can rank by total grant dollars and cap at TOP_N.
const perFoundation = []; // { ein, name, total, grants[] }
for (const [batch, oidMap] of byBatch) {
// Find the actual zip file on disk case-insensitively (batch id is lowercased).
const diskName = fs.readdirSync(RAW).find((f) => f.toLowerCase() === `${batch}.zip`);
const dir = await unzipper.Open.file(path.join(RAW, diskName));
// index zip entries by object id substring for quick lookup
const wanted = new Set(oidMap.keys());
let hit = 0;
for (const f of dir.files) {
const base = path.basename(f.path);
const m = base.match(/^(\d+)_public\.xml$/);
if (!m) continue;
const oid = m[1];
if (!wanted.has(oid)) continue;
hit++;
const t = oidMap.get(oid);
const xml = (await f.buffer()).toString('utf8');
let obj;
try { obj = parser.parse(xml); } catch { continue; }
const grants = extractGrants(obj, oid);
const total = grants.reduce((s, g) => s + (g.amount || 0), 0);
perFoundation.push({ ein: t.ein, name: t.name, total, grants });
}
console.log(` ${batch}: matched ${hit}/${wanted.size} target XMLs.`);
}
// Rank by total grant dollars, cap at TOP_N.
perFoundation.sort((a, b) => b.total - a.total);
const withGrants = perFoundation.filter((f) => f.grants.length);
const chosen = withGrants.slice(0, TOP_N);
const skippedByCap = Math.max(0, withGrants.length - chosen.length);
console.log(`Foundations with >=1 grant: ${withGrants.length}; taking top ${chosen.length} by grant $, skipping ${skippedByCap} beyond cap.`);
// Load chosen foundations' grants.
let batch = [];
for (const f of chosen) {
foundationsCovered++;
for (const g of f.grants) {
batch.push(g);
if (batch.length >= 1000) { grantsUpserted += await flush(batch); batch = []; }
}
}
if (batch.length) grantsUpserted += await flush(batch);
const detail = `covered ${foundationsCovered} foundations (top ${TOP_N} by grant $); `
+ `${grantsUpserted} grant rows; skippedByCap ${skippedByCap}; `
+ `unreachable(no batch dl) ${foundationsSkippedNoBatch} of ${targets.size} indexed.`;
await run.done(chosen.length, grantsUpserted, 'ok', detail);
console.log(`IRS 990-PF grants: ${detail} $0 (IRS bulk).`);
} catch (e) {
await run.done(foundationsCovered, grantsUpserted, 'error', String(e.message));
console.error('FAILED:', e.stack || e.message);
process.exitCode = 1;
} finally {
await pool.end();
}
}
main();