/** * Corporation Status Check API * * GET /api/v1/corp/status?name=Acme+LLC&state=WY * Search entity_cache for corporation status (ACTIVE, DELINQUENT, etc.) * Returns status + years behind + cost to remediate. * * GET /api/v1/corp/search?q=Acme&state=WY * Fuzzy search entity_cache by name. Returns up to 10 matches. * * Used by: * - Standalone Corporation Status Check tool (/tools/corporation-check) * - FCC Compliance Check (adds corporation_status check) */ import { Router, type Request, type Response } from "express"; import { pool } from "../db.js"; const router = Router(); // ── RA pricing by state ────────────────────────────────────────────────────── const RA_PRICE_CENTS: Record = { WY: 5000, // $50/yr Wyoming }; const RA_DEFAULT_CENTS = 9900; // $99/yr all other states // Our flat markup per annual report or reinstatement filing const FILING_MARKUP_CENTS = 2500; // $25 // ── Helpers ────────────────────────────────────────────────────────────────── interface CorpStatusResult { found: boolean; entity_name: string | null; entity_number: string | null; entity_type: string | null; status: string | null; formation_date: string | null; dissolution_date: string | null; registered_agent: string | null; principal_address: string | null; state: string | null; // Computed fields years_behind: number; annual_report_fee_cents: number; annual_report_frequency: string | null; cost_per_year_cents: number; total_catchup_cents: number; ra_price_cents: number; total_with_ra_cents: number; breakdown: string; } /** * Look up corporation status from entity_cache and compute remediation cost. * Exported so fcc-lookup.ts can call it directly. */ export async function lookupCorpStatus( entityName: string, stateCode: string, ): Promise { if (!entityName || !stateCode) return null; const state = stateCode.toUpperCase().trim(); const name = entityName.trim(); // Try exact match first, then fuzzy (trigram) let row: Record | null = null; try { const exact = await pool.query( `SELECT entity_name, entity_number, entity_type, status, formation_date, dissolution_date, registered_agent, state FROM entity_cache WHERE state = $1 AND LOWER(entity_name) = LOWER($2) LIMIT 1`, [state, name], ); if (exact.rows.length > 0) { row = exact.rows[0] as Record; } else { // Fuzzy match — require very high similarity (0.8+) to avoid false positives // like "GTDIAL DATA SOLUTIONS" matching "SPATIAL DATA SOLUTIONS" (0.68) const fuzzy = await pool.query( `SELECT entity_name, entity_number, entity_type, status, formation_date, dissolution_date, registered_agent, state, similarity(entity_name, $2) AS sim FROM entity_cache WHERE state = $1 AND similarity(entity_name, $2) > 0.8 ORDER BY sim DESC LIMIT 1`, [state, name], ); if (fuzzy.rows.length > 0) { row = fuzzy.rows[0] as Record; } } } catch (err: any) { // pg_trgm extension may not be available, or entity_cache empty if (err?.code !== "42P01") { console.warn("[corp-status] entity_cache query failed:", err?.message); } return null; } if (!row) { // Live Playwright fallback for states without bulk data (WY, DE, etc.) // Calls the workers /entity-status endpoint which uses the state adapter const WORKER_URL = process.env.WORKER_URL || "http://workers:8090"; console.log(`[corp-status] Cache miss for "${name}" in ${state} — trying live search via workers`); try { const liveRes = await fetch(`${WORKER_URL}/entity-status`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ entity_name: name, state_code: state }), signal: AbortSignal.timeout(45000), }); const liveData = await liveRes.json() as Record; console.log(`[corp-status] Live search result:`, JSON.stringify(liveData).slice(0, 200)); if (liveRes.ok && liveData.found && liveData.entity_name) { row = { entity_name: liveData.entity_name, entity_number: liveData.entity_number || null, entity_type: liveData.entity_type || null, status: liveData.status || "UNKNOWN", formation_date: liveData.formation_date || null, dissolution_date: null, registered_agent: liveData.registered_agent || null, state, }; } } catch { // Worker unavailable or timeout — skip this state } } if (!row) return null; const status = (row.status as string) || "UNKNOWN"; const formationDate = row.formation_date ? new Date(row.formation_date as string).toISOString().slice(0, 10) : null; // Look up annual report obligation for this state let annualFee = 0; let frequency = "annual"; let dueMonth: number | null = null; let isAnniversary = false; try { const obl = await pool.query( `SELECT fee_cents, frequency, due_month, is_anniversary FROM state_compliance_obligations WHERE state_code = $1 AND obligation_type = 'annual_report' LIMIT 1`, [state], ); if (obl.rows.length > 0) { const o = obl.rows[0] as Record; annualFee = (o.fee_cents as number) || 0; frequency = (o.frequency as string) || "annual"; dueMonth = (o.due_month as number) || null; isAnniversary = (o.is_anniversary as boolean) || false; } } catch { // state_compliance_obligations table may not exist } // Calculate years behind let yearsBehind = 0; if (status !== "ACTIVE" && formationDate) { const formed = new Date(formationDate); const now = new Date(); const yearsExisted = now.getFullYear() - formed.getFullYear(); if (frequency === "biennial") { yearsBehind = Math.max(1, Math.floor(yearsExisted / 2)); } else if (frequency === "annual") { // Conservative estimate: if delinquent, assume at least 1 year behind // Most states revoke after 2-3 years of non-filing if (status === "DELINQUENT" || status === "SUSPENDED") { yearsBehind = Math.max(1, Math.min(3, yearsExisted - 1)); } else if (status === "DISSOLVED" || status === "INACTIVE") { yearsBehind = Math.max(1, Math.min(5, yearsExisted - 1)); } } } // Cost calculations const costPerYear = annualFee + FILING_MARKUP_CENTS; const totalCatchup = yearsBehind * costPerYear; const raPrice = RA_PRICE_CENTS[state] || RA_DEFAULT_CENTS; const totalWithRA = totalCatchup + raPrice; // Build human-readable breakdown let breakdown = ""; if (yearsBehind > 0) { const stateFeeStr = `$${(annualFee / 100).toFixed(0)}`; const markupStr = `$${(FILING_MARKUP_CENTS / 100).toFixed(0)}`; const perYearStr = `$${(costPerYear / 100).toFixed(0)}`; const totalStr = `$${(totalCatchup / 100).toFixed(0)}`; const raStr = `$${(raPrice / 100).toFixed(0)}`; const grandStr = `$${(totalWithRA / 100).toFixed(0)}`; if (yearsBehind === 1) { breakdown = `Annual report: ${stateFeeStr} state fee + ${markupStr} filing = ${perYearStr}. `; } else { breakdown = `Annual reports (${yearsBehind} years): ${yearsBehind} × (${stateFeeStr} + ${markupStr}) = ${totalStr}. `; } breakdown += `Registered agent: ${raStr}/yr. Total: ${grandStr}.`; } return { found: true, entity_name: (row.entity_name as string) || null, entity_number: (row.entity_number as string) || null, entity_type: (row.entity_type as string) || null, status, formation_date: formationDate, dissolution_date: row.dissolution_date ? new Date(row.dissolution_date as string).toISOString().slice(0, 10) : null, registered_agent: (row.registered_agent as string) || null, principal_address: (row.principal_address as string) || null, state, years_behind: yearsBehind, annual_report_fee_cents: annualFee, annual_report_frequency: frequency, cost_per_year_cents: costPerYear, total_catchup_cents: totalCatchup, ra_price_cents: raPrice, total_with_ra_cents: totalWithRA, breakdown, }; } // ── GET /api/v1/corp/search ────────────────────────────────────────────────── router.get("/api/v1/corp/search", async (req: Request, res: Response) => { const q = (req.query.q as string || "").trim(); const state = (req.query.state as string || "").toUpperCase().trim(); if (!q || q.length < 2) { res.status(400).json({ error: "q parameter required (min 2 chars)" }); return; } if (!state || state.length !== 2) { res.status(400).json({ error: "state parameter required (2-letter code)" }); return; } try { const { rows } = await pool.query( `SELECT entity_name, entity_number, entity_type, status, formation_date, similarity(entity_name, $2) AS sim FROM entity_cache WHERE state = $1 AND entity_name % $2 ORDER BY sim DESC LIMIT 10`, [state, q], ); res.json({ state, query: q, count: rows.length, results: rows.map((r: any) => ({ entity_name: r.entity_name, entity_number: r.entity_number, entity_type: r.entity_type, status: r.status, formation_date: r.formation_date ? new Date(r.formation_date).toISOString().slice(0, 10) : null, })), }); } catch (err: any) { if (err?.code === "42P01") { res.json({ state, query: q, count: 0, results: [], note: "Entity database not yet populated for this state." }); } else { console.error("[corp/search] Error:", err); res.status(500).json({ error: "Search failed" }); } } }); // ── GET /api/v1/corp/status ────────────────────────────────────────────────── router.get("/api/v1/corp/status", async (req: Request, res: Response) => { const name = (req.query.name as string || "").trim(); const state = (req.query.state as string || "").toUpperCase().trim(); if (!name) { res.status(400).json({ error: "name parameter required" }); return; } if (!state || state.length !== 2) { res.status(400).json({ error: "state parameter required (2-letter code)" }); return; } const result = await lookupCorpStatus(name, state); if (!result) { res.json({ found: false, state, searched_name: name, note: "Entity not found in our database. Try the standalone state search or check directly with the Secretary of State.", }); return; } res.json(result); }); // ── GET /api/v1/corp/states ────────────────────────────────────────────────── // Returns list of states with annual report info for the UI dropdown router.get("/api/v1/corp/states", async (_req: Request, res: Response) => { try { const { rows } = await pool.query(` SELECT s.state_code, s.fee_cents, s.frequency, s.due_description, s.is_anniversary, (SELECT count(*) FROM entity_cache WHERE state = s.state_code) AS entity_count FROM state_compliance_obligations s WHERE s.obligation_type = 'annual_report' ORDER BY s.state_code `); res.json({ states: rows.map((r: any) => ({ code: r.state_code, annual_report_fee_cents: r.fee_cents, frequency: r.frequency, due: r.due_description, is_anniversary: r.is_anniversary, entities_cached: parseInt(r.entity_count, 10), ra_price_cents: RA_PRICE_CENTS[r.state_code] || RA_DEFAULT_CENTS, })), }); } catch (err) { console.error("[corp/states] Error:", err); res.status(500).json({ error: "Failed to load state data" }); } }); // ── POST /api/v1/corp/foreign-qual-check ──────────────────────────────────── // // Bulk check: given an entity name, home state, and list of states served, // check entity_cache for foreign qualification status in each state. // Used by the 499-A intake wizard after the JurisdictionStep. router.post("/api/v1/corp/foreign-qual-check", async (req: Request, res: Response) => { try { const { entity_name, home_state, states } = req.body as { entity_name?: string; home_state?: string; states?: string[]; }; if (!entity_name || !states || !Array.isArray(states) || states.length === 0) { res.status(400).json({ error: "entity_name and states[] required" }); return; } const homeState = (home_state || "").toUpperCase().trim(); const name = entity_name.trim(); // Check each state (skip home state — they're already formed there) // Use entity_cache only (no live Playwright fallback — too slow for bulk) const results: Array<{ state_code: string; has_data: boolean; found: boolean; status: string | null; entity_name_found: string | null; needs_foreign_qual: boolean; reason: string; }> = []; // Check which states have data in entity_cache so we can distinguish // "not found" from "no data for this state" let statesWithData: Set; try { const dataCheck = await pool.query( `SELECT DISTINCT state FROM entity_cache WHERE state = ANY($1)`, [states.map(s => s.toUpperCase().trim())], ); statesWithData = new Set(dataCheck.rows.map((r: any) => r.state)); } catch { statesWithData = new Set(); } for (const stateCode of states) { const state = stateCode.toUpperCase().trim(); if (state === homeState) continue; // Skip home state let found = false; let status: string | null = null; let foundName: string | null = null; const hasData = statesWithData.has(state); if (hasData) { try { // Exact match const exact = await pool.query( `SELECT entity_name, status FROM entity_cache WHERE state = $1 AND LOWER(entity_name) = LOWER($2) LIMIT 1`, [state, name], ); if (exact.rows.length > 0) { found = true; status = (exact.rows[0] as any).status; foundName = (exact.rows[0] as any).entity_name; } else { // Fuzzy match const fuzzy = await pool.query( `SELECT entity_name, status, similarity(entity_name, $2) AS sim FROM entity_cache WHERE state = $1 AND similarity(entity_name, $2) > 0.8 ORDER BY sim DESC LIMIT 1`, [state, name], ); if (fuzzy.rows.length > 0) { found = true; status = (fuzzy.rows[0] as any).status; foundName = (fuzzy.rows[0] as any).entity_name; } } } catch { // entity_cache or pg_trgm not available — skip } } let needsFQ = false; let reason = ""; if (!hasData) { // No entity_cache data for this state — can't determine, skip reason = "No data available for this state yet"; } else if (!found) { needsFQ = true; reason = "No foreign corporation registration found in this state"; } else if (status && ["DISSOLVED", "REVOKED", "CANCELLED", "SUSPENDED", "INACTIVE"].includes(status.toUpperCase())) { needsFQ = true; reason = `Registration found but status is ${status} — reinstatement or new filing needed`; } else if (status && status.toUpperCase() === "DELINQUENT") { needsFQ = true; reason = `Registration found but delinquent — annual report or reinstatement needed`; } results.push({ state_code: state, has_data: hasData, found, status, entity_name_found: foundName, needs_foreign_qual: needsFQ, reason, }); } const missing = results.filter((r) => r.needs_foreign_qual); res.json({ entity_name: name, home_state: homeState, total_states_checked: results.length, states_missing: missing.length, results, foreign_qual_service_fee_cents: 9900, // $99/state for multi-state }); } catch (err) { console.error("[corp/foreign-qual-check] Error:", err); res.status(500).json({ error: "Foreign qualification check failed" }); } }); export default router;