← back to Ca Donations

scripts/ingest-ca-ag-status.mjs

74 lines

// Ingest CA Attorney General Registry of Charitable Trusts status lists into charitable_orgs.
// Sets ca_ag_status per source file, keyed by FEIN (normalized to strein XX-XXXXXXX so it
// joins ProPublica orgs). Reversible local load. MAX_ROWS bounds the proof; unset = full.
// Source: https://oag.ca.gov/charities/reports  (updated 1st & 3rd Wed monthly)
import { parse } from 'csv-parse';
import { q, startRun, upsertSource } from '../lib/db.js';
import { pool } from '../lib/db.js';

const FILES = {
  'may-operate':      'charities-may-operate.csv',
  'may-not-operate':  'charities-may-not-operate.csv',
  'undetermined':     'charities-undetermined-status.csv',
  'not-operating':    'charities-not-operating.csv',
};
const WHICH = process.env.CA_AG_LIST || 'may-operate';
const MAX_ROWS = process.env.MAX_ROWS ? parseInt(process.env.MAX_ROWS, 10) : Infinity;
const BASE = 'https://oag.ca.gov/sites/all/files/agweb/pdfs/charities/reports/';
const SLUG = 'ca_ag';

const clean = (s) => (s == null ? null : String(s).trim() || null);
const toStrein = (fein) => {
  const d = clean(fein)?.replace(/\D/g, '');
  return d && d.length === 9 ? `${d.slice(0, 2)}-${d.slice(2)}` : (clean(fein) || null);
};

async function main() {
  const file = FILES[WHICH];
  if (!file) throw new Error(`Unknown CA_AG_LIST '${WHICH}'`);
  await upsertSource({
    slug: SLUG, name: 'CA AG Registry of Charitable Trusts', url: 'https://oag.ca.gov/charities/reports',
    jurisdiction: 'state', family: 'charitable', granularity: 'org', access: 'csv',
    notes: 'Registration/status lists (may-operate/may-not/undetermined/not-operating). Monthly CSV.',
  });
  const run = await startRun(SLUG);
  let seen = 0, upserted = 0;
  try {
    const res = await fetch(BASE + file, { headers: { 'User-Agent': 'ca-donations/0.1 (public-records)' } });
    if (!res.ok) throw new Error(`CA AG HTTP ${res.status} for ${file}`);
    // This government CSV has stray, unbalanced double-quotes inside fields. It is not
    // genuinely quoted, so strip all " and parse as plain comma-delimited (quote off).
    const text = (await res.text()).replace(/"/g, '');
    const records = await new Promise((resolve, reject) => {
      parse(text, { columns: true, skip_empty_lines: true, relax_column_count: true, quote: false, trim: true },
        (err, out) => (err ? reject(err) : resolve(out)));
    });
    for (const rec of records) {
      if (seen >= MAX_ROWS) break;
      seen++;
      const ein = toStrein(rec.FEIN);
      const name = clean(rec.Name);
      if (!name) continue;
      // Prefer keying on EIN; fall back to a synthetic key when FEIN is blank.
      const r = await q(
        `INSERT INTO charitable_orgs (ein,name,city,state,ca_ag_status,source_slug)
         VALUES ($1,$2,$3,$4,$5,$6)
         ON CONFLICT (ein) DO UPDATE SET ca_ag_status=EXCLUDED.ca_ag_status,
           name=COALESCE(charitable_orgs.name, EXCLUDED.name)
         RETURNING id`,
        [ein, name, clean(rec.City), clean(rec.State), WHICH, SLUG],
      );
      if (r.length) upserted++;
    }
    await run.done(seen, upserted, 'ok', `list=${WHICH}`);
    console.log(`CA AG ${WHICH}: ${seen} rows read, ${upserted} orgs upserted. $0 (free CSV).`);
  } catch (e) {
    await run.done(seen, upserted, 'error', String(e.message));
    console.error('FAILED:', e.message);
    process.exitCode = 1;
  } finally {
    await pool.end();
  }
}
main();