corp-status: surface annual-report-overdue signal (backfilled for CT)

entity_cache had CT status but no annual-report clock, so the corporation-check
tool told overdue-but-active CT businesses they were fine. Now:
- backfill_ct_annual_report_due.py: pulls annual_report_due_date from the live
  CT Socrata registry into entity_cache.annual_report_due_date (keyed by
  accountnumber=entity_number). Ran: 577,562 CT rows populated, 235,875 overdue.
- corp-status API: reads annual_report_due_date, returns annual_report_overdue +
  days_overdue, and bumps years_behind to >=1 for an overdue-but-active entity so
  the remediation cost isn't $0. This makes the tool match the CT campaign hook.
This commit is contained in:
justin 2026-07-01 08:39:53 -05:00
parent b016caecea
commit 42a7beb655
2 changed files with 160 additions and 1 deletions

View file

@ -43,6 +43,11 @@ interface CorpStatusResult {
state: string | null;
// Computed fields
years_behind: number;
// Annual-report deadline signal (from entity_cache.annual_report_due_date,
// currently backfilled for CT). null when we have no due date for this entity.
annual_report_due_date: string | null;
annual_report_overdue: boolean;
annual_report_days_overdue: number | null;
annual_report_fee_cents: number;
annual_report_frequency: string | null;
cost_per_year_cents: number;
@ -71,7 +76,8 @@ export async function lookupCorpStatus(
try {
const exact = await pool.query(
`SELECT entity_name, entity_number, entity_type, status,
formation_date, dissolution_date, registered_agent, state
formation_date, dissolution_date, registered_agent, state,
annual_report_due_date
FROM entity_cache
WHERE state = $1 AND LOWER(entity_name) = LOWER($2)
LIMIT 1`,
@ -86,6 +92,7 @@ export async function lookupCorpStatus(
const fuzzy = await pool.query(
`SELECT entity_name, entity_number, entity_type, status,
formation_date, dissolution_date, registered_agent, state,
annual_report_due_date,
similarity(entity_name, $2) AS sim
FROM entity_cache
WHERE state = $1 AND similarity(entity_name, $2) > 0.8
@ -129,6 +136,7 @@ export async function lookupCorpStatus(
dissolution_date: null,
registered_agent: liveData.registered_agent || null,
state,
annual_report_due_date: null,
};
}
} catch {
@ -188,6 +196,28 @@ export async function lookupCorpStatus(
}
}
// Annual-report deadline signal (from entity_cache.annual_report_due_date,
// backfilled for CT). This catches the common case the status field misses: a
// business still "ACTIVE" whose annual report is nonetheless past due. When
// overdue, ensure yearsBehind reflects at least the current lapse so the cost
// estimate isn't $0 for a genuinely-overdue (but not-yet-forfeited) entity.
const dueRaw = row.annual_report_due_date
? new Date(row.annual_report_due_date as string)
: null;
const annualReportDueDate = dueRaw ? dueRaw.toISOString().slice(0, 10) : null;
let annualReportOverdue = false;
let annualReportDaysOverdue: number | null = null;
if (dueRaw && !Number.isNaN(dueRaw.getTime())) {
const days = Math.floor((Date.now() - dueRaw.getTime()) / 86400000);
annualReportDaysOverdue = days;
annualReportOverdue = days > 0;
if (annualReportOverdue && yearsBehind === 0) {
// Overdue-but-active: at least the current year's report is owed. Add a
// second year only once it's more than ~a year late.
yearsBehind = days > 400 ? 2 : 1;
}
}
// Cost calculations
const costPerYear = annualFee + FILING_MARKUP_CENTS;
const totalCatchup = yearsBehind * costPerYear;
@ -226,6 +256,9 @@ export async function lookupCorpStatus(
principal_address: (row.principal_address as string) || null,
state,
years_behind: yearsBehind,
annual_report_due_date: annualReportDueDate,
annual_report_overdue: annualReportOverdue,
annual_report_days_overdue: annualReportDaysOverdue,
annual_report_fee_cents: annualFee,
annual_report_frequency: frequency,
cost_per_year_cents: costPerYear,