diff --git a/src/index.ts b/src/index.ts index 14fba3b..94408c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,6 +54,8 @@ export type Env = { CHITTYASSETS_URL?: string; CHITTYSCRAPE_URL?: string; CHITTYROUTER_URL?: string; + CHITTYGOV_URL?: string; + CHITTYGOV_TOKEN?: string; CHITTYAGENT_SCRAPE_URL?: string; CHITTYREGISTER_URL?: string; CHITTYCHAT_DATA_API?: string; diff --git a/src/lib/cron.ts b/src/lib/cron.ts index 95c94dd..482a17f 100644 --- a/src/lib/cron.ts +++ b/src/lib/cron.ts @@ -1,6 +1,6 @@ import type { NeonQueryFunction } from '@neondatabase/serverless'; import type { Env } from '../index'; -import { plaidClient, financeClient, mercuryClient, scrapeClient, routerClient } from './integrations'; +import { plaidClient, financeClient, mercuryClient, scrapeClient, routerClient, govClient } from './integrations'; import { runTriage } from './triage'; import { matchTransactions } from './matcher'; import { generateProjections } from './projections'; @@ -135,6 +135,19 @@ export async function runCronSync( } catch (err) { console.error('[cron:dispute_reconcile] failed:', err); } + + // Phase 11: Governance compliance check + // Pulls upcoming deadlines from ChittyGov, upserts into cc_obligations, + // and enqueues verification scrapes for monitors with scrapers. + try { + const govSynced = await syncGovernanceCompliance(env, sql); + if (govSynced > 0) { + recordsSynced += govSynced; + console.log(`[cron:governance] synced ${govSynced} compliance filings`); + } + } catch (err) { + console.error('[cron:governance] failed:', err); + } } if (source === 'utility_scrape') { @@ -856,3 +869,135 @@ export async function syncEmailParsedBills(env: Env, sql: NeonQueryFunction): Promise { + const gov = govClient(env); + if (!gov) { + console.warn('[governance] ChittyGov not configured — skipping'); + return 0; + } + + let synced = 0; + let failed = 0; + + // Pull upcoming filings within 60 days + const calendarResult = await gov.getComplianceCalendar({ status: 'upcoming,due_soon,overdue', days: 60 }); + if (!calendarResult?.filings || !Array.isArray(calendarResult.filings)) { + console.warn('[governance] No filings returned from ChittyGov'); + return 0; + } + + for (const filing of calendarResult.filings) { + // Upsert into cc_obligations as governance category + const payee = `${filing.jurisdiction} — ${filing.filingType.replace(/_/g, ' ')}`; + const rawAmount = filing.fee ? Number(filing.fee) : 0; + const amount = isNaN(rawAmount) ? 0 : rawAmount; + const rawPenalty = filing.latePenalty ? Number(filing.latePenalty) : null; + const latePenalty = rawPenalty !== null && !isNaN(rawPenalty) ? rawPenalty : null; + + try { + const [existing] = await sql` + SELECT id FROM cc_obligations + WHERE category = 'governance' + AND metadata->>'filing_id' = ${filing.filingId} + LIMIT 1 + `; + + if (existing) { + await sql` + UPDATE cc_obligations + SET status = ${filing.status === 'overdue' ? 'overdue' : 'pending'}, + due_date = ${filing.dueDate}, + amount_due = CASE WHEN ${amount} > 0 THEN ${amount} ELSE amount_due END, + metadata = jsonb_set( + COALESCE(metadata, '{}'::jsonb), + '{last_gov_sync}', + ${JSON.stringify({ syncedAt: new Date().toISOString(), daysUntil: filing.daysUntil, latePenalty: filing.latePenalty })}::jsonb + ), + updated_at = NOW() + WHERE id = ${existing.id} + `; + } else { + await sql` + INSERT INTO cc_obligations (category, subcategory, payee, amount_due, due_date, recurrence, status, late_fee, metadata) + VALUES ( + 'governance', + ${filing.filingType}, + ${payee}, + ${amount}, + ${filing.dueDate}, + ${filing.filingType === 'annual_report' ? 'yearly' : filing.filingType === 'tax_estimate' ? 'quarterly' : null}, + ${filing.status === 'overdue' ? 'overdue' : 'pending'}, + ${latePenalty}, + ${JSON.stringify({ + filing_id: filing.filingId, + jurisdiction: filing.jurisdiction, + entity_name: filing.entityName, + authority_url: filing.authorityUrl, + source: 'chittygov_sync', + })}::jsonb + ) + `; + } + synced++; + } catch (dbErr) { + failed++; + console.error(`[governance] DB error for filing ${filing.filingId}:`, dbErr); + } + } + + if (failed > 0) { + console.warn(`[governance] synced ${synced}, failed ${failed} of ${calendarResult.filings.length} filings`); + } + + // Enqueue verification scrapes for active monitors + try { + const monitorsResult = await gov.getMonitors('active'); + if (!monitorsResult?.monitors) { + console.warn('[governance] Failed to fetch monitors from ChittyGov — skipping enqueue'); + } else { + let chittyId: string | undefined; + try { + chittyId = await env.COMMAND_KV.get('default:chitty_id') || undefined; + } catch (kvErr) { + console.error('[governance] KV read for chitty_id failed:', kvErr); + } + for (const monitor of monitorsResult.monitors) { + if (!monitor.scraperId) continue; + + // Map monitor types to scrape job types + const jobTypeMap: Record = { + sos_status: 'sos_status', + registered_agent: 'portal_scrape', + recorder_filings: 'recorder_filings', + assessment_status: 'assessor_check', + }; + const jobType = jobTypeMap[monitor.monitorType]; + if (!jobType) continue; + + try { + await enqueueJob(sql, jobType, { + scraper_id: monitor.scraperId, + monitor_id: monitor.monitorId, + ...((monitor.scrapeInput as Record) || {}), + }, { + chittyId, + cronSource: 'governance', + }, env); + } catch (err) { + console.error(`[governance] enqueue ${monitor.monitorId} failed:`, err); + } + } + } + } catch (err) { + console.error('[governance] monitor fetch/enqueue failed:', err); + } + + return synced; +} diff --git a/src/lib/integrations.ts b/src/lib/integrations.ts index e4fa537..2639037 100644 --- a/src/lib/integrations.ts +++ b/src/lib/integrations.ts @@ -995,3 +995,105 @@ export function notionClient(env: Env) { }, }; } + +// ── ChittyGov ───────────────────────────────────────────── +// Corporate governance: compliance calendar, filing deadlines, monitors + +export interface ComplianceFiling { + filingId: string; + entityId: number; + entityName?: string; + filingType: string; + jurisdiction: string; + dueDate: string; + status: string; + daysUntil: number; + fee?: string; + latePenalty?: string; + authorityUrl?: string; +} + +export interface ComplianceMonitor { + monitorId: string; + entityId?: number; + entityName?: string; + monitorType: string; + scraperId?: string; + scrapeInput?: Record; + checkFrequency: string; + lastCheckedAt?: string; + status: string; +} + +export function govClient(env: Env) { + const govUrl = env.CHITTYGOV_URL; + if (!govUrl) return null; + + const headers: Record = { + 'Content-Type': 'application/json', + 'X-Source-Service': 'chittycommand', + }; + if (env.CHITTYGOV_TOKEN) { + headers['Authorization'] = `Bearer ${env.CHITTYGOV_TOKEN}`; + } + + return { + getComplianceCalendar: async (params?: { status?: string; days?: number; entityId?: string }): Promise<{ filings: ComplianceFiling[]; total: number } | null> => { + try { + const qs = new URLSearchParams(); + if (params?.status) qs.set('status', params.status); + if (params?.days) qs.set('days', String(params.days)); + if (params?.entityId) qs.set('entity_id', params.entityId); + const url = `${govUrl}/api/compliance/calendar${qs.toString() ? `?${qs}` : ''}`; + const res = await fetch(url, { headers, signal: AbortSignal.timeout(10000) }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + console.error(`[gov] getComplianceCalendar failed: ${res.status} — ${body.slice(0, 500)}`); + return null; + } + return await res.json() as { filings: ComplianceFiling[]; total: number }; + } catch (err) { + console.error('[gov] getComplianceCalendar error:', err); + return null; + } + }, + + verifyFiling: async (filingId: string, data?: { source?: string; data?: Record }): Promise => { + try { + const res = await fetch(`${govUrl}/api/compliance/verify/${encodeURIComponent(filingId)}`, { + method: 'POST', + headers, + body: JSON.stringify(data || {}), + signal: AbortSignal.timeout(10000), + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + console.error(`[gov] verifyFiling failed: ${res.status} — ${body.slice(0, 500)}`); + } + return res.ok; + } catch (err) { + console.error('[gov] verifyFiling error:', err); + return false; + } + }, + + getMonitors: async (status?: string): Promise<{ monitors: ComplianceMonitor[]; total: number } | null> => { + try { + const qs = status ? `?status=${status}` : ''; + const res = await fetch(`${govUrl}/api/compliance/monitors${qs}`, { + headers, + signal: AbortSignal.timeout(10000), + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + console.error(`[gov] getMonitors failed: ${res.status} — ${body.slice(0, 500)}`); + return null; + } + return await res.json() as { monitors: ComplianceMonitor[]; total: number }; + } catch (err) { + console.error('[gov] getMonitors error:', err); + return null; + } + }, + }; +} diff --git a/src/lib/job-dispatcher.ts b/src/lib/job-dispatcher.ts index 375c5a5..13d273a 100644 --- a/src/lib/job-dispatcher.ts +++ b/src/lib/job-dispatcher.ts @@ -7,7 +7,10 @@ export type ScrapeJobType = | 'court_docket' | 'cook_county_tax' | 'mr_cooper' - | 'portal_scrape'; + | 'portal_scrape' + | 'sos_status' + | 'recorder_filings' + | 'assessor_check'; export type ScrapeJobStatus = | 'queued' diff --git a/src/routes/jobs.ts b/src/routes/jobs.ts index 2e3bbd2..da646c7 100644 --- a/src/routes/jobs.ts +++ b/src/routes/jobs.ts @@ -65,7 +65,7 @@ jobRoutes.post('/jobs', async (c) => { return c.json({ error: 'job_type and target are required' }, 400); } - const validTypes: ScrapeJobType[] = ['court_docket', 'cook_county_tax', 'mr_cooper', 'portal_scrape']; + const validTypes: ScrapeJobType[] = ['court_docket', 'cook_county_tax', 'mr_cooper', 'portal_scrape', 'sos_status', 'recorder_filings', 'assessor_check']; if (!validTypes.includes(body.job_type)) { return c.json({ error: `Invalid job_type. Must be one of: ${validTypes.join(', ')}` }, 400); } diff --git a/src/routes/mcp.ts b/src/routes/mcp.ts index 31b3bee..c377e03 100644 --- a/src/routes/mcp.ts +++ b/src/routes/mcp.ts @@ -5,7 +5,7 @@ import { getDb, typedRows } from '../lib/db'; import type { NeonQueryFunction } from '@neondatabase/serverless'; import { listJobs, getJobStatus, retryJob, getDeadLetters, enqueueJob } from '../lib/job-dispatcher'; import type { ScrapeJobType, ScrapeJobStatus } from '../lib/job-dispatcher'; -import { evidenceClient, ledgerClient } from '../lib/integrations'; +import { evidenceClient, ledgerClient, govClient } from '../lib/integrations'; /** * MCP (Model Context Protocol) server for ChittyCommand. @@ -302,7 +302,7 @@ const TOOLS = [ type: 'object' as const, properties: { status: { type: 'string', description: 'Filter by status', enum: ['queued', 'running', 'completed', 'failed', 'retrying', 'dead_letter'] }, - type: { type: 'string', description: 'Filter by job type', enum: ['court_docket', 'cook_county_tax', 'mr_cooper', 'portal_scrape'] }, + type: { type: 'string', description: 'Filter by job type', enum: ['court_docket', 'cook_county_tax', 'mr_cooper', 'portal_scrape', 'sos_status', 'recorder_filings', 'assessor_check'] }, chitty_id: { type: 'string', description: 'Filter by ChittyID' }, limit: { type: 'number', description: 'Max results (default 20)' }, }, @@ -348,13 +348,39 @@ const TOOLS = [ inputSchema: { type: 'object' as const, properties: { - job_type: { type: 'string', description: 'Type of scrape', enum: ['court_docket', 'cook_county_tax', 'mr_cooper', 'portal_scrape'] }, + job_type: { type: 'string', description: 'Type of scrape', enum: ['court_docket', 'cook_county_tax', 'mr_cooper', 'portal_scrape', 'sos_status', 'recorder_filings', 'assessor_check'] }, target: { type: 'object', description: 'Target parameters (e.g. {case_number}, {pin}, {property}, {portal})' }, chitty_id: { type: 'string', description: 'Optional ChittyID to bind results to' }, }, required: ['job_type', 'target'], }, }, + // ── Governance Compliance ────────────────────────────────── + { + name: 'query_compliance_calendar', + description: 'Get upcoming governance/compliance filing deadlines from ChittyGov. Returns LLC annual reports, tax estimates, and other filings with days until due.', + inputSchema: { + type: 'object' as const, + properties: { + status: { type: 'string', description: 'Filter by status (upcoming, due_soon, overdue, verified). Comma-separated for multiple.' }, + days: { type: 'number', description: 'Only show filings due within this many days (default: 90)' }, + entity_id: { type: 'string', description: 'Filter by entity ID (e.g. ENT-JAV)' }, + }, + required: [] as string[], + }, + }, + { + name: 'verify_compliance_filing', + description: 'Mark a governance filing as verified (filed/completed). Triggers recurrence roll-forward for recurring filings.', + inputSchema: { + type: 'object' as const, + properties: { + filing_id: { type: 'string', description: 'Filing ID (e.g. FIL-JAV-annual_report-2026)' }, + source: { type: 'string', description: 'Verification source (scrape, email, manual)' }, + }, + required: ['filing_id'], + }, + }, { name: 'synthesize_case_facts', description: 'Auto-pull verified facts from ChittyEvidence for a case and synthesize them into a structured litigation summary. Returns categorized facts with [GIVEN]/[DERIVED]/[UNKNOWN] tags.', @@ -1161,7 +1187,7 @@ async function executeTool(env: Env, sql: NeonQueryFunction, toolN const target = args.target as Record; if (!jobType || !target) throw new Error('Missing arguments: job_type, target'); - const validTypes: ScrapeJobType[] = ['court_docket', 'cook_county_tax', 'mr_cooper', 'portal_scrape']; + const validTypes: ScrapeJobType[] = ['court_docket', 'cook_county_tax', 'mr_cooper', 'portal_scrape', 'sos_status', 'recorder_filings', 'assessor_check']; if (!validTypes.includes(jobType)) throw new Error(`Invalid job_type. Must be one of: ${validTypes.join(', ')}`); const jobId = await enqueueJob(sql, jobType, target, { @@ -1171,6 +1197,32 @@ async function executeTool(env: Env, sql: NeonQueryFunction, toolN return { ok: true, id: jobId, status: 'queued' }; } + // ── Governance Compliance Tool Handlers ──────────────── + case 'query_compliance_calendar': { + const gov = govClient(env); + if (!gov) return { error: 'ChittyGov not configured (CHITTYGOV_URL missing)' }; + const result = await gov.getComplianceCalendar({ + status: args.status ? String(args.status) : undefined, + days: args.days ? Number(args.days) : 90, + entityId: args.entity_id ? String(args.entity_id) : undefined, + }); + if (!result) return { error: 'Failed to fetch compliance calendar from ChittyGov' }; + return result; + } + + case 'verify_compliance_filing': { + const filingId = String(args.filing_id || '').trim(); + if (!filingId) throw new Error('Missing argument: filing_id'); + const gov = govClient(env); + if (!gov) return { error: 'ChittyGov not configured (CHITTYGOV_URL missing)' }; + const ok = await gov.verifyFiling(filingId, { + source: args.source ? String(args.source) : 'mcp', + }); + return ok + ? { ok: true, filingId, message: 'Filing marked as verified' } + : { error: 'Failed to verify filing', filingId }; + } + case 'synthesize_case_facts': { const caseId = String(args.case_id || '').trim(); if (!caseId) throw new Error('Missing argument: case_id'); diff --git a/src/routes/sync.ts b/src/routes/sync.ts index 7eacebd..8fa11b2 100644 --- a/src/routes/sync.ts +++ b/src/routes/sync.ts @@ -2,7 +2,7 @@ import { Hono } from 'hono'; import type { Env } from '../index'; import { getDb } from '../lib/db'; import { matchTransactions } from '../lib/matcher'; -import { syncMercury, syncPlaid, syncFinance, syncCourtDocket, syncMrCooper, syncCookCountyTax, syncPortal } from '../lib/cron'; +import { syncMercury, syncPlaid, syncFinance, syncCourtDocket, syncMrCooper, syncCookCountyTax, syncPortal, syncGovernanceCompliance } from '../lib/cron'; export const syncRoutes = new Hono<{ Bindings: Env }>(); @@ -25,6 +25,7 @@ syncRoutes.post('/trigger/:source', async (c) => { 'mercury', 'plaid', 'chittyfinance', 'wave', 'stripe', 'turbotenant', 'chittyrental', 'court_docket', 'mr_cooper', 'cook_county_tax', + 'sos_status', 'recorder_filings', 'assessor_check', 'comed', 'peoples_gas', 'xfinity', 'citi', 'home_depot', 'lowes', ]; @@ -52,6 +53,9 @@ syncRoutes.post('/trigger/:source', async (c) => { court_docket: () => syncCourtDocket(c.env, sql), mr_cooper: () => syncMrCooper(c.env, sql), cook_county_tax: () => syncCookCountyTax(c.env, sql), + sos_status: () => syncGovernanceCompliance(c.env, sql), + recorder_filings: () => syncGovernanceCompliance(c.env, sql), + assessor_check: () => syncGovernanceCompliance(c.env, sql), }; // Resolve aliases and portal sources to their dispatcher