Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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 @@ -53,6 +53,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