Includes: API (Express/TypeScript), Astro site, Python workers, document generators, FCC compliance tools, Canada CRTC formation, Ansible infrastructure, and deployment scripts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
481 lines
17 KiB
TypeScript
481 lines
17 KiB
TypeScript
/**
|
||
* 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<string, number> = {
|
||
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<CorpStatusResult | null> {
|
||
if (!entityName || !stateCode) return null;
|
||
|
||
const state = stateCode.toUpperCase().trim();
|
||
const name = entityName.trim();
|
||
|
||
// Try exact match first, then fuzzy (trigram)
|
||
let row: Record<string, unknown> | 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<string, unknown>;
|
||
} 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<string, unknown>;
|
||
}
|
||
}
|
||
} 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<string, unknown>;
|
||
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<string, unknown>;
|
||
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<string>;
|
||
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;
|