Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export type Env = {
CHITTYASSETS_URL?: string;
CHITTYSCRAPE_URL?: string;
CHITTYROUTER_URL?: string;
CHITTYGOV_URL?: string;
CHITTYGOV_TOKEN?: string;
Comment thread
chitcommit marked this conversation as resolved.
CHITTYAGENT_SCRAPE_URL?: string;
CHITTYREGISTER_URL?: string;
CHITTYCHAT_DATA_API?: string;
Expand Down
147 changes: 146 additions & 1 deletion src/lib/cron.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -856,3 +869,135 @@ export async function syncEmailParsedBills(env: Env, sql: NeonQueryFunction<fals
console.log(`[email_bills] ingested ${synced} items from ChittyRouter`);
return synced;
}

/**
* Sync governance compliance deadlines from ChittyGov.
* Pulls upcoming filings (60-day window), upserts into cc_obligations
* as category='governance', and enqueues verification scrapes for
* monitors that have a scraper_id.
*/
export async function syncGovernanceCompliance(env: Env, sql: NeonQueryFunction<false, false>): Promise<number> {
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
`;
Comment thread
chitcommit marked this conversation as resolved.

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()
Comment thread
chitcommit marked this conversation as resolved.
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<string, ScrapeJobType> = {
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<string, unknown>) || {}),
}, {
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;
}
102 changes: 102 additions & 0 deletions src/lib/integrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
checkFrequency: string;
lastCheckedAt?: string;
status: string;
}

export function govClient(env: Env) {
const govUrl = env.CHITTYGOV_URL;
if (!govUrl) return null;

const headers: Record<string, string> = {
'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<string, unknown> }): Promise<boolean> => {
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;
}
},
};
}
5 changes: 4 additions & 1 deletion src/lib/job-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion src/routes/jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Loading
Loading