← back to Costa Rica

scripts/ingest/hacienda-enricher.js

128 lines

'use strict';
// Hacienda (Ministerio de Hacienda CR) enrichment pass.
// Endpoint: https://api.hacienda.go.cr/fe/ae?identificacion={cedula}
// Returns: { nombre, tipoIdentificacion, regimen.codigo/descripcion, situacion.moroso/omiso/estado, actividades:[{estado,codigo,tipo,descripcion}] }
// Throttle: 1500ms between calls. Cap per tick: env HACIENDA_BATCH (default 200).
//
// Usage:
//   node scripts/ingest/hacienda-enricher.js                # process default batch
//   HACIENDA_BATCH=10 node scripts/ingest/hacienda-enricher.js   # quick test
//   HACIENDA_RETRY_ERRORS=1 ...                             # also re-try rows with prior errors
//
// Re-runs are safe: rows with hacienda_enriched_at IS NOT NULL are skipped (unless HACIENDA_RETRY_ERRORS=1
// AND they have a stored error).

const { fetchJson, sleep, pool } = require('./_lib');

const BATCH = parseInt(process.env.HACIENDA_BATCH || '200', 10);
const THROTTLE_MS = parseInt(process.env.HACIENDA_THROTTLE_MS || '1500', 10);
const RETRY_ERRORS = process.env.HACIENDA_RETRY_ERRORS === '1';
const ENDPOINT = 'https://api.hacienda.go.cr/fe/ae?identificacion=';

function normalizeCedula(c) {
  if (!c) return null;
  return String(c).replace(/[^0-9]/g, '');
}

(async () => {
  const t0 = Date.now();
  const { rows: runIns } = await pool.query(
    `INSERT INTO ingest_runs (source, status) VALUES ('hacienda-enricher','running') RETURNING id`
  );
  const runId = runIns[0].id;

  let q;
  if (RETRY_ERRORS) {
    q = `SELECT id, slug, cedula_juridica
           FROM places
          WHERE cedula_juridica IS NOT NULL
            AND (hacienda_enriched_at IS NULL OR hacienda_error IS NOT NULL)
       ORDER BY hacienda_enriched_at NULLS FIRST, id
          LIMIT $1`;
  } else {
    q = `SELECT id, slug, cedula_juridica
           FROM places
          WHERE cedula_juridica IS NOT NULL
            AND hacienda_enriched_at IS NULL
       ORDER BY id
          LIMIT $1`;
  }
  const { rows: targets } = await pool.query(q, [BATCH]);
  console.log(`[hacienda] queued ${targets.length} rows (batch=${BATCH}, throttle=${THROTTLE_MS}ms, retry=${RETRY_ERRORS})`);

  let ok = 0, miss = 0, err = 0;

  for (const r of targets) {
    const ced = normalizeCedula(r.cedula_juridica);
    if (!ced || ced.length < 9) {
      await pool.query(
        `UPDATE places SET hacienda_enriched_at=NOW(), hacienda_error=$1 WHERE id=$2`,
        ['invalid-cedula-format', r.id]
      );
      err++;
      continue;
    }

    try {
      const j = await fetchJson(ENDPOINT + encodeURIComponent(ced), {
        headers: { 'Accept': 'application/json' },
      });

      // Defensive: API may return {nombre:null} for non-registered cedulas
      const officialName = j.nombre || null;
      const tipoId = j.tipoIdentificacion || null;
      const situacion = j.situacion?.estado || null;
      const regimen = j.regimen?.descripcion || null;
      const actividades = Array.isArray(j.actividades) ? j.actividades : null;

      if (!officialName && !situacion && !actividades?.length) {
        await pool.query(
          `UPDATE places SET hacienda_enriched_at=NOW(), hacienda_error=$1 WHERE id=$2`,
          ['no-data-returned', r.id]
        );
        miss++;
      } else {
        await pool.query(
          `UPDATE places
              SET hacienda_official_name=$1,
                  hacienda_tipo_id=$2,
                  hacienda_situacion=$3,
                  hacienda_regimen=$4,
                  hacienda_actividades=$5,
                  hacienda_enriched_at=NOW(),
                  hacienda_error=NULL
            WHERE id=$6`,
          [officialName, tipoId, situacion, regimen,
           actividades ? JSON.stringify(actividades) : null,
           r.id]
        );
        ok++;
      }
    } catch (e) {
      const msg = String(e.message || e).slice(0, 200);
      await pool.query(
        `UPDATE places SET hacienda_enriched_at=NOW(), hacienda_error=$1 WHERE id=$2`,
        [`fetch-error: ${msg}`, r.id]
      );
      err++;
    }

    if ((ok + miss + err) % 25 === 0) {
      console.log(`[hacienda] progress ${ok + miss + err}/${targets.length} ok=${ok} miss=${miss} err=${err}`);
    }
    await sleep(THROTTLE_MS);
  }

  const dt = ((Date.now() - t0) / 1000).toFixed(1);
  console.log(`[hacienda] done in ${dt}s — ok=${ok} miss=${miss} err=${err}`);

  await pool.query(
    `UPDATE ingest_runs
        SET finished_at=NOW(), rows_in=$1, rows_updated=$2, status='ok',
            notes=$3
      WHERE id=$4`,
    [targets.length, ok, `miss=${miss} err=${err} batch=${BATCH}`, runId]
  );
  await pool.end();
})();