Initial commit — Performance West telecom compliance platform
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>
This commit is contained in:
commit
f8cd37ac8c
1823 changed files with 145167 additions and 0 deletions
481
api/src/routes/corp-status.ts
Normal file
481
api/src/routes/corp-status.ts
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
/**
|
||||
* 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue