diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0f98dcf --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,132 @@ +# AGENTS.md + +## Project Overview + +ChittyCommand is a unified life management and action dashboard for the ChittyOS ecosystem. It ingests data from 15+ financial, legal, and administrative sources, scores urgency with AI, recommends actions, and executes them via APIs, email, or browser automation. + +**Repo:** `CHITTYOS/chittycommand` +**Deploy:** Cloudflare Workers at `command.chitty.cc` +**Stack:** Hono TypeScript, React + Tailwind, Neon PostgreSQL (via Hyperdrive), Cloudflare R2/KV +**Canonical URI:** `chittycanon://core/services/chittycommand` | Tier 5 + +## Common Commands + +```bash +npm run dev # Start Hono dev server (wrangler dev) +npm run deploy # Deploy to Cloudflare Workers +npm run ui:dev # Start React frontend dev server (localhost:5173) +npm run ui:build # Build frontend for Pages +npm run db:generate # Generate Drizzle migrations +npm run db:migrate # Run Drizzle migrations +``` + +Secrets are managed via wrangler (never hardcode): +```bash +wrangler secret put PLAID_CLIENT_ID +wrangler secret put PLAID_SECRET +wrangler secret put DATABASE_URL +``` + +## Architecture + +Single Cloudflare Worker (`chittycommand`) serving API + cron. Frontend is a separate React SPA at `app.command.chitty.cc` (Cloudflare Pages). + +### Data Sources + +**Via ChittyFinance (auto-sync):** Mercury, Wave Accounting, Stripe, Plaid +**Direct API:** ChittyBooks, ChittyAssets, ChittyCharge, ChittyLedger +**Via ChittyScrape (bridge):** Mr. Cooper mortgage, Cook County property tax, Court docket +**Email Parse:** ComEd, Peoples Gas, Xfinity, Citi, Home Depot, Lowe's +**Manual:** IRS quarterly, HOA fees, Personal loans +**Historical Only:** DoorLoop (sunset, data archived) + +### Auth Flow + +Three auth layers in `src/middleware/auth.ts`: +1. **`authMiddleware`** (`/api/*`) — KV token lookup, then ChittyAuth fallback +2. **`bridgeAuthMiddleware`** (`/api/bridge/*`) — Service token OR user token +3. **`mcpAuthMiddleware`** (`/mcp/*`) — ChittyAuth token validation first, shared KV service token fallback (bypassed in dev) + +### Cron Schedule + +Defined in `wrangler.toml`, dispatched via `src/lib/cron.ts`: +- `0 12 * * *` — Daily 6 AM CT: Plaid + ChittyFinance sync +- `0 13 * * *` — Daily 7 AM CT: Court docket check +- `0 14 * * 1` — Weekly Mon 8 AM CT: Utility scrapers +- `0 15 1 * *` — Monthly 1st 9 AM CT: Mortgage, property tax + +### Database + +Neon PostgreSQL via Hyperdrive binding. All tables prefixed `cc_`. Schema in `src/db/schema.ts`, SQL migrations in `migrations/` (0001-0007). + +### Action Execution + +Three modes: +1. **API** — Mercury transfers, Stripe payments via bridge routes +2. **Codex in Chrome** — Browser automation for portals without APIs +3. **Email** — Dispute letters, follow-ups via Cloudflare Email Workers + +## Key Files + +- `src/index.ts` — Hono entry point, route mounting, health/status endpoints +- `src/middleware/auth.ts` — Auth middleware (user, bridge, MCP) +- `src/lib/cron.ts` — Cron sync orchestrator (all data sources) +- `src/lib/integrations.ts` — Service clients (Mercury, Plaid, ChittyScrape, etc.) +- `src/lib/urgency.ts` — Deterministic urgency scoring engine +- `src/lib/validators.ts` — Zod schemas for request validation +- `src/routes/bridge.ts` — Inter-service bridge (scrape, ledger, finance, Plaid) +- `src/routes/mcp.ts` — MCP server for Codex integration (28 tools) +- `src/routes/meta.ts` — Public canon/schema/beacon + authenticated whoami +- `src/routes/connect.ts` — ChittyConnect discovery proxy (rate-limited) +- `src/routes/ledger.ts` — ChittyLedger evidence/custody passthrough +- `src/routes/context.ts` — Persona/context management (user + global) +- `src/routes/auth.ts` — Login/verify flows +- `src/routes/token-management.ts` — Admin token CRUD +- `src/routes/dashboard.ts` — Dashboard summary with urgency scoring +- `src/db/schema.ts` — Drizzle schema for all cc_* tables +- `migrations/` — SQL migration files (0001-0007) +- `ui/` — React frontend (Vite + Tailwind) + +## Security + +- Credentials via 1Password (`op run`) — never expose in terminal output +- Secrets via `wrangler secret put` — never in `[vars]` +- R2 for document storage (zero egress) +- CORS restricted to `app.command.chitty.cc`, `command.mychitty.com`, `chittycommand-ui.pages.dev`, `localhost:5173` +- Service tokens stored in KV: `bridge:service_token`, `mcp:service_token`, `scrape:service_token` + +## Codex Setup + +Use Codex (or Codex Desktop with MCP support) to connect directly to the `/mcp` HTTP server. + +- Server URL: `https://command.chitty.cc/mcp` +- Transport: HTTP (JSON‑RPC 2.0, streamable HTTP) +- Auth (production): `Authorization: Bearer ` (preferred) or legacy `Authorization: Bearer ` +- Auth (dev): If `ENVIRONMENT != 'production'`, auth is bypassed + +Example client-side MCP configuration (conceptual): + +``` +{ + "mcpServers": { + "chittycommand": { + "type": "http", + "url": "https://command.chitty.cc/mcp", + "headers": { "Authorization": "Bearer " } + } + } +} +``` + +The server exposes 28 tools across 8 domains: + +**Core meta** — `get_canon_info`, `get_registry_status`, `get_schema_refs`, `whoami`, `get_context_summary` +**Financial** — `query_obligations`, `query_accounts`, `query_disputes`, `get_recommendations`, `get_cash_position`, `get_cashflow_projections` +**Ledger** — `ledger_stats`, `ledger_get_evidence`, `ledger_record_custody`, `ledger_facts`, `ledger_contradictions`, `ledger_create_case_for_dispute`, `ledger_link_case_for_dispute` +**Connect** — `connect_discover` +**ChittyChat** — `chittychat_list_projects`, `chittychat_list_tasks`, `chittychat_get_task` +**Schema** — `schema_list_types`, `schema_get`, `schema_validate`, `schema_drift` +**Cert** — `cert_verify` +**Register** — `register_requirements` + +Tools return structured JSON using MCP `content: [{ type: "json", json: ... }]` where applicable, enabling Codex to consume results without text parsing. diff --git a/docs/plans/2026-03-05-adversarial-review-restated.md b/docs/plans/2026-03-05-adversarial-review-restated.md index 391e602..030dfc8 100644 --- a/docs/plans/2026-03-05-adversarial-review-restated.md +++ b/docs/plans/2026-03-05-adversarial-review-restated.md @@ -81,3 +81,25 @@ - Added reusable confirmation dialog. - Added Dashboard pay confirmation and feedback toasts. - Added Action Queue auto-triage on first empty load and decision feedback toasts. + +## Update (2026-03-08) + +- P1 double-write fix implemented: + - `POST /api/queue/:id/decide` no longer mutates obligation status. + - Action Queue approval now explicitly calls `/api/obligations/:id/pay` for payment-type recommendations. + - Queue decision state and payment execution are now separated to avoid duplicate writes. + +- P2 dispute lifecycle foundation implemented: + - Added `cc_disputes.stage` via migration + schema/validators. + - Added stage/status consistency rules in dispute create/update APIs. + - Replaced synthetic dispute progress with DB-backed stage progress in UI. + - Added dispute creation form in frontend. + - Added direct document upload from dispute context (`linked_dispute_id` flow). + +- P3 flow-connection updates implemented: + - Legal deadlines now include linked dispute metadata (via `case_ref` to dispute metadata case ids). + - Added `GET /api/disputes/:id/deadlines` for dispute-context deadline visibility. + - Legal page now deep-links into Disputes using `?expand={dispute_id}`. + - Disputes page supports `Deadlines` panel and URL-based auto-expand. + - Added `POST /api/payment-plan/:id/enqueue` to convert plan schedule items into queue recommendations. + - Payment Planner UI now supports `Send to Queue` with success/error feedback. diff --git a/migrations/0010_dispute_stages.sql b/migrations/0010_dispute_stages.sql new file mode 100644 index 0000000..4a5e0bd --- /dev/null +++ b/migrations/0010_dispute_stages.sql @@ -0,0 +1,18 @@ +-- Add explicit lifecycle stage for disputes. +ALTER TABLE cc_disputes + ADD COLUMN IF NOT EXISTS stage TEXT DEFAULT 'filed'; + +-- Backfill existing rows. +UPDATE cc_disputes +SET stage = CASE + WHEN status IN ('resolved', 'dismissed') THEN 'resolved' + ELSE 'filed' +END +WHERE stage IS NULL; + +ALTER TABLE cc_disputes + ALTER COLUMN stage SET DEFAULT 'filed'; + +ALTER TABLE cc_disputes + ALTER COLUMN stage SET NOT NULL; + diff --git a/src/routes/disputes.ts b/src/routes/disputes.ts index 29bba0d..e3db00f 100644 --- a/src/routes/disputes.ts +++ b/src/routes/disputes.ts @@ -5,6 +5,7 @@ import { ledgerClient } from '../lib/integrations'; import { createDisputeSchema, updateDisputeSchema, createCorrespondenceSchema, disputeQuerySchema } from '../lib/validators'; export const disputeRoutes = new Hono<{ Bindings: Env }>(); +const TERMINAL_STATUSES = new Set(['resolved', 'dismissed']); // List disputes disputeRoutes.get('/', async (c) => { @@ -34,16 +35,49 @@ disputeRoutes.get('/:id', async (c) => { return c.json({ ...dispute, correspondence, documents }); }); +// Get deadlines linked to this dispute (via ledger_case_id/case_ref metadata) +disputeRoutes.get('/:id/deadlines', async (c) => { + const sql = getDb(c.env); + const id = c.req.param('id'); + + const [dispute] = await sql` + SELECT id, title, COALESCE(metadata->>'ledger_case_id', metadata->>'case_ref') AS case_ref + FROM cc_disputes + WHERE id = ${id} + `; + if (!dispute) return c.json({ error: 'Dispute not found' }, 404); + + const caseRef = (dispute as { case_ref: string | null }).case_ref; + if (!caseRef) return c.json([]); + + const deadlines = await sql` + SELECT * + FROM cc_legal_deadlines + WHERE case_ref = ${caseRef} + AND status != 'completed' + ORDER BY deadline_date ASC + `; + return c.json(deadlines); +}); + // Create dispute disputeRoutes.post('/', async (c) => { const raw = await c.req.json(); const result = createDisputeSchema.safeParse(raw); if (!result.success) return c.json({ error: 'Validation failed', issues: result.error.issues }, 400); const body = result.data; + const stage = body.stage || 'filed'; + const status = body.status || 'open'; + if (stage !== 'resolved' && status !== 'open') { + return c.json({ error: 'Non-terminal stages must keep status=open' }, 400); + } + if (stage === 'resolved' && status === 'open') { + return c.json({ error: 'stage=resolved requires status=resolved or status=dismissed' }, 400); + } const sql = getDb(c.env); const [dispute] = await sql` - INSERT INTO cc_disputes (title, counterparty, dispute_type, amount_claimed, amount_at_stake, status, priority, description, next_action, next_action_date, resolution_target, metadata) - VALUES (${body.title}, ${body.counterparty}, ${body.dispute_type}, ${body.amount_claimed || null}, ${body.amount_at_stake || null}, ${body.status || 'open'}, ${body.priority || 5}, ${body.description || null}, ${body.next_action || null}, ${body.next_action_date || null}, ${body.resolution_target || null}, ${JSON.stringify(body.metadata || {})}) + INSERT INTO cc_disputes (title, counterparty, dispute_type, amount_claimed, amount_at_stake, stage, status, priority, description, next_action, next_action_date, resolution_target, metadata) + VALUES (${body.title}, ${body.counterparty}, ${body.dispute_type}, ${body.amount_claimed || null}, ${body.amount_at_stake || null}, ${stage}, ${status}, ${body.priority || 5}, ${body.description || null}, ${body.next_action || null}, ${body.next_action_date || null}, ${body.resolution_target || null}, ${JSON.stringify(body.metadata || {})}) RETURNING * `; // Fire-and-forget: push to ChittyLedger as a case @@ -72,17 +106,51 @@ disputeRoutes.patch('/:id', async (c) => { if (!result.success) return c.json({ error: 'Validation failed', issues: result.error.issues }, 400); const body = result.data; const sql = getDb(c.env); + const [existing] = await sql`SELECT * FROM cc_disputes WHERE id = ${id}`; + if (!existing) return c.json({ error: 'Dispute not found' }, 404); + + const nextStage = (body.stage ?? existing.stage ?? 'filed') as string; + const nextStatus = (body.status ?? existing.status ?? 'open') as string; + if (nextStage !== 'resolved' && nextStatus !== 'open') { + return c.json({ error: 'Non-terminal stages must keep status=open' }, 400); + } + if (nextStage === 'resolved' && !TERMINAL_STATUSES.has(nextStatus)) { + return c.json({ error: 'stage=resolved requires status=resolved or status=dismissed' }, 400); + } + const [dispute] = await sql` UPDATE cc_disputes SET - status = COALESCE(${body.status ?? null}, status), + title = COALESCE(${body.title ?? null}, title), + counterparty = COALESCE(${body.counterparty ?? null}, counterparty), + dispute_type = COALESCE(${body.dispute_type ?? null}, dispute_type), + amount_claimed = COALESCE(${body.amount_claimed ?? null}, amount_claimed), + amount_at_stake = COALESCE(${body.amount_at_stake ?? null}, amount_at_stake), + stage = ${nextStage}, + status = ${nextStatus}, priority = COALESCE(${body.priority ?? null}, priority), next_action = COALESCE(${body.next_action ?? null}, next_action), next_action_date = COALESCE(${body.next_action_date ?? null}, next_action_date), description = COALESCE(${body.description ?? null}, description), + resolution_target = COALESCE(${body.resolution_target ?? null}, resolution_target), + metadata = COALESCE(${body.metadata ? JSON.stringify(body.metadata) : null}::jsonb, metadata), updated_at = NOW() WHERE id = ${id} RETURNING * `; - if (!dispute) return c.json({ error: 'Dispute not found' }, 404); + + if (body.stage || body.status) { + await sql` + INSERT INTO cc_actions_log (action_type, target_type, target_id, description, request_payload, status) + VALUES ( + 'dispute_lifecycle_update', + 'dispute', + ${id}, + ${`Lifecycle update: stage=${nextStage}, status=${nextStatus}`}, + ${JSON.stringify({ before: { stage: existing.stage, status: existing.status }, after: { stage: nextStage, status: nextStatus } })}, + 'completed' + ) + `; + } + return c.json(dispute); }); diff --git a/src/routes/documents.ts b/src/routes/documents.ts index d238683..61578db 100644 --- a/src/routes/documents.ts +++ b/src/routes/documents.ts @@ -4,6 +4,7 @@ import { getDb } from '../lib/db'; import { ledgerClient } from '../lib/integrations'; export const documentRoutes = new Hono<{ Bindings: Env }>(); +const UUID_V4ISH = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; documentRoutes.get('/', async (c) => { const sql = getDb(c.env); @@ -22,7 +23,14 @@ const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25MB documentRoutes.post('/upload', async (c) => { const formData = await c.req.formData(); const file = formData.get('file') as unknown as File | null; + const linkedDisputeRaw = formData.get('linked_dispute_id'); + const linkedDisputeId = typeof linkedDisputeRaw === 'string' && linkedDisputeRaw.trim().length > 0 + ? linkedDisputeRaw.trim() + : null; if (!file || typeof file === 'string') return c.json({ error: 'No file provided' }, 400); + if (linkedDisputeId && !UUID_V4ISH.test(linkedDisputeId)) { + return c.json({ error: 'Invalid linked_dispute_id' }, 400); + } if (!ALLOWED_TYPES.has(file.type)) { return c.json({ error: 'Unsupported file type', allowed: [...ALLOWED_TYPES] }, 400); @@ -43,8 +51,8 @@ documentRoutes.post('/upload', async (c) => { // Create DB record const [doc] = await sql` - INSERT INTO cc_documents (doc_type, source, filename, r2_key, processing_status) - VALUES ('upload', 'manual', ${safeName}, ${r2Key}, 'pending') + INSERT INTO cc_documents (doc_type, source, filename, r2_key, linked_dispute_id, processing_status) + VALUES ('upload', 'manual', ${safeName}, ${r2Key}, ${linkedDisputeId}, 'pending') RETURNING * `; diff --git a/src/routes/legal.ts b/src/routes/legal.ts index 30ebb72..af49b60 100644 --- a/src/routes/legal.ts +++ b/src/routes/legal.ts @@ -8,8 +8,22 @@ export const legalRoutes = new Hono<{ Bindings: Env }>(); legalRoutes.get('/', async (c) => { const sql = getDb(c.env); const deadlines = await sql` - SELECT * FROM cc_legal_deadlines - WHERE status != 'completed' + SELECT + l.*, + d.id AS dispute_id, + d.title AS dispute_title + FROM cc_legal_deadlines l + LEFT JOIN LATERAL ( + SELECT id, title, status, priority, created_at + FROM cc_disputes d + WHERE l.case_ref = COALESCE(d.metadata->>'ledger_case_id', d.metadata->>'case_ref') + ORDER BY + CASE WHEN d.status = 'open' THEN 0 ELSE 1 END, + d.priority ASC NULLS LAST, + d.created_at DESC + LIMIT 1 + ) d ON TRUE + WHERE l.status != 'completed' ORDER BY deadline_date ASC `; return c.json(deadlines); diff --git a/src/routes/payment-plan.ts b/src/routes/payment-plan.ts index d844080..48888bb 100644 --- a/src/routes/payment-plan.ts +++ b/src/routes/payment-plan.ts @@ -89,3 +89,87 @@ paymentPlanRoutes.post('/:id/activate', async (c) => { if (!plan) return c.json({ error: 'Plan not found' }, 404); return c.json(plan); }); + +// POST /api/payment-plan/:id/enqueue — convert plan schedule entries into queue recommendations +paymentPlanRoutes.post('/:id/enqueue', async (c) => { + const id = c.req.param('id'); + const sql = getDb(c.env); + + const [plan] = await sql` + SELECT id, plan_type, schedule + FROM cc_payment_plans + WHERE id = ${id}::uuid + `; + if (!plan) return c.json({ error: 'Plan not found' }, 404); + + let schedule: Array<{ + date: string; + obligation_id: string; + payee: string; + amount: number; + action: string; + escalation_risk?: string | null; + }> = []; + try { + const raw = typeof plan.schedule === 'string' ? JSON.parse(plan.schedule) : plan.schedule; + if (Array.isArray(raw)) schedule = raw; + } catch { + return c.json({ error: 'Invalid plan schedule payload' }, 400); + } + + let created = 0; + let skipped = 0; + for (const entry of schedule) { + if (!entry?.obligation_id) { + skipped++; + continue; + } + if (entry.action !== 'pay_full' && entry.action !== 'pay_minimum') { + skipped++; + continue; + } + + const dueDate = new Date(entry.date); + const daysOut = Math.floor((dueDate.getTime() - Date.now()) / 86400000); + const priority = daysOut <= 3 ? 1 : daysOut <= 7 ? 2 : daysOut <= 14 ? 3 : 4; + const actionType = entry.action === 'pay_minimum' ? 'pay_minimum' : 'pay_full'; + const title = `Plan: ${actionType === 'pay_minimum' ? 'Pay minimum' : 'Pay'} ${entry.payee} on ${entry.date}`; + const reasoning = `Scheduled by ${plan.plan_type} plan ${plan.id} for ${entry.date}.`; + + const [existing] = await sql` + SELECT id + FROM cc_recommendations + WHERE obligation_id = ${entry.obligation_id}::uuid + AND status = 'active' + AND action_type = ${actionType} + AND title = ${title} + LIMIT 1 + `; + if (existing) { + skipped++; + continue; + } + + await sql` + INSERT INTO cc_recommendations ( + obligation_id, rec_type, priority, title, reasoning, action_type, + suggested_amount, escalation_risk, model_version, confidence + ) + VALUES ( + ${entry.obligation_id}::uuid, + 'payment_plan', + ${priority}, + ${title}, + ${reasoning}, + ${actionType}, + ${entry.amount || null}, + ${entry.escalation_risk || null}, + 'plan-enqueue-v1', + 0.90 + ) + `; + created++; + } + + return c.json({ plan_id: id, created, skipped }); +}); diff --git a/src/routes/swipe-queue.ts b/src/routes/swipe-queue.ts index 8f7dea8..d335442 100644 --- a/src/routes/swipe-queue.ts +++ b/src/routes/swipe-queue.ts @@ -119,7 +119,7 @@ swipeQueueRoutes.post('/:id/decide', async (c) => { ) `; - // Execute the decision — require 'admin' or 'chittycommand:execute' scope for approvals + // Record the decision — require 'admin' or 'chittycommand:execute' scope for approvals if (decision === 'approved') { const scopes = c.get('scopes') || []; if (!scopes.includes('admin') && !scopes.includes('chittycommand:execute')) { @@ -143,23 +143,9 @@ swipeQueueRoutes.post('/:id/decide', async (c) => { 'completed' ) `; + // Keep queue decision and obligation execution separate. + // Payment/defer side-effects are executed explicitly via dedicated endpoints. - // If it's a payment action tied to an obligation, mark obligation as paid - const actionType = modified_action || r.action_type as string; - if ((actionType === 'pay_now' || actionType === 'pay_full' || actionType === 'pay_minimum') && r.obligation_id) { - await sql` - UPDATE cc_obligations SET status = 'paid', updated_at = NOW() - WHERE id = ${r.obligation_id as string}::uuid - `; - } - - // If it's a defer action, mark as deferred - if (actionType === 'defer' && r.obligation_id) { - await sql` - UPDATE cc_obligations SET status = 'deferred', updated_at = NOW() - WHERE id = ${r.obligation_id as string}::uuid - `; - } } else if (decision === 'rejected') { await sql` UPDATE cc_recommendations SET status = 'dismissed', acted_on_at = NOW() diff --git a/ui/src/components/planner/PaymentPlanView.tsx b/ui/src/components/planner/PaymentPlanView.tsx index 80f584a..b2f5fdd 100644 --- a/ui/src/components/planner/PaymentPlanView.tsx +++ b/ui/src/components/planner/PaymentPlanView.tsx @@ -6,9 +6,11 @@ import { MetricCard } from '../ui/MetricCard'; interface PaymentPlanViewProps { plan: PaymentPlan; onDeferItem?: (obligationId: string) => void; + onSendToQueue?: () => void; + enqueueLoading?: boolean; } -export function PaymentPlanView({ plan, onDeferItem }: PaymentPlanViewProps) { +export function PaymentPlanView({ plan, onDeferItem, onSendToQueue, enqueueLoading }: PaymentPlanViewProps) { let schedule: ScheduleEntry[]; try { const raw = typeof plan.schedule === 'string' ? JSON.parse(plan.schedule) : plan.schedule; @@ -43,6 +45,22 @@ export function PaymentPlanView({ plan, onDeferItem }: PaymentPlanViewProps) { return (
+ {onSendToQueue && plan.id && ( + +
+

Execution Bridge

+

Send scheduled payments to Action Queue.

+
+ +
+ )} + {/* Summary metrics */}
diff --git a/ui/src/pages/ActionQueue.tsx b/ui/src/pages/ActionQueue.tsx index 18b7d83..c848dfb 100644 --- a/ui/src/pages/ActionQueue.tsx +++ b/ui/src/pages/ActionQueue.tsx @@ -87,17 +87,36 @@ export function ActionQueue() { loadStats(); }, [loadQueue, loadStats]); + const isPaymentAction = useCallback((item: QueueItem) => { + const type = item.action_type; + return type === 'pay_now' || type === 'pay_full' || type === 'pay_minimum'; + }, []); + const handleDecide = useCallback(async (id: string, decision: 'approved' | 'rejected' | 'deferred') => { try { const current = items.find((item) => item.id === id); await api.decideQueue(id, decision, sessionId.current); + + // Queue approval now only records decision; execute payment explicitly. + if (decision === 'approved' && current?.obligation_id && isPaymentAction(current)) { + try { + await api.markPaid(current.obligation_id); + toast.success('Payment executed', current.title, { durationMs: 2200 }); + } catch (paymentErr: unknown) { + const message = paymentErr instanceof Error ? paymentErr.message : 'Payment execution failed'; + toast.error('Approved, but execution failed', message); + } + } + setItems((prev) => prev.filter((item) => item.id !== id)); loadStats(); if (current) { const title = current.title.length > 60 ? `${current.title.slice(0, 57)}...` : current.title; if (decision === 'approved') { - toast.success('Approved', title, { durationMs: 2000 }); + if (!current.obligation_id || !isPaymentAction(current)) { + toast.success('Approved', title, { durationMs: 2000 }); + } } else if (decision === 'rejected') { toast.info('Rejected', title, { durationMs: 2000 }); } else { @@ -109,7 +128,7 @@ export function ActionQueue() { setError(message); toast.error('Decision failed', message); } - }, [items, loadStats, toast]); + }, [isPaymentAction, items, loadStats, toast]); const handleGenerate = useCallback(async () => { const generated = await runTriage(false); diff --git a/ui/src/pages/CashFlow.tsx b/ui/src/pages/CashFlow.tsx index 92fc044..29f8823 100644 --- a/ui/src/pages/CashFlow.tsx +++ b/ui/src/pages/CashFlow.tsx @@ -9,6 +9,7 @@ import { StrategySelector } from '../components/planner/StrategySelector'; import { RevenueSources } from '../components/planner/RevenueSources'; import { formatCurrency, formatDate, cn } from '../lib/utils'; import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer, ReferenceLine } from 'recharts'; +import { useToast } from '../lib/toast'; type CashFlowTab = 'projections' | 'planner' | 'revenue'; @@ -18,20 +19,25 @@ export function CashFlow() { const [summary, setSummary] = useState(null); const [obligations, setObligations] = useState([]); const [scenario, setScenario] = useState(null); + const [revenueSummary, setRevenueSummary] = useState<{ count: number; total_monthly: number; weighted_monthly: number } | null>(null); const [deferIds, setDeferIds] = useState>(new Set()); const [loading, setLoading] = useState(true); const [generating, setGenerating] = useState(false); const [error, setError] = useState(null); + const [enqueueLoading, setEnqueueLoading] = useState(false); + const toast = useToast(); const load = () => { setLoading(true); Promise.all([ api.getCashflowProjections(), api.getObligations({ status: 'pending' }), + api.getRevenueSources(), ]) - .then(([proj, obs]) => { + .then(([proj, obs, rev]) => { setProjections(proj); setObligations(obs); + setRevenueSummary(rev.summary); setError(null); }) .catch((e) => setError(e instanceof Error ? e.message : 'Failed to load cash flow data')) @@ -83,6 +89,8 @@ export function CashFlow() { inflow: parseFloat(p.projected_inflow), outflow: parseFloat(p.projected_outflow), })); + const projectedEnding = chartData.length > 0 ? chartData[chartData.length - 1].balance : null; + const projectedLowest = chartData.length > 0 ? Math.min(...chartData.map((c) => c.balance)) : null; // Payment planner state — must be before any conditional returns (Rules of Hooks) const [plan, setPlan] = useState(null); @@ -125,7 +133,7 @@ export function CashFlow() { setError(null); try { const result = await api.simulatePaymentPlan({ strategy: planStrategy, defer_ids: newDeferIds }); - setPlan(result); + setPlan((prev) => ({ ...result, id: prev?.id })); } catch (e: unknown) { setPlanDeferIds(prevDeferIds); setError(e instanceof Error ? e.message : 'Simulation failed'); @@ -140,13 +148,31 @@ export function CashFlow() { setError(null); try { const result = await api.simulatePaymentPlan({ strategy: planStrategy, ...overrides }); - setPlan(result); + setPlan((prev) => ({ ...result, id: prev?.id })); setPlanDeferIds(overrides.defer_ids ?? []); } catch (e: unknown) { setError(e instanceof Error ? e.message : 'Simulation failed'); } }; + const handleSendPlanToQueue = async () => { + if (!plan?.id) return; + setEnqueueLoading(true); + try { + const result = await api.enqueuePlan(plan.id); + toast.success( + 'Plan sent to queue', + `${result.created} created, ${result.skipped} skipped`, + { durationMs: 2500 }, + ); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'Failed to send plan to queue'; + toast.error('Could not send plan to queue', msg); + } finally { + setEnqueueLoading(false); + } + }; + return (
@@ -167,6 +193,27 @@ export function CashFlow() { )}
+ {/* Tabs */} + +
+ + + +
+
+ {/* Tabs */}
{(['projections', 'planner', 'revenue'] as CashFlowTab[]).map((tab) => ( @@ -198,7 +245,12 @@ export function CashFlow() { {plan ? ( <> - + ) : ( diff --git a/ui/src/pages/Disputes.tsx b/ui/src/pages/Disputes.tsx index 435b624..d74be02 100644 --- a/ui/src/pages/Disputes.tsx +++ b/ui/src/pages/Disputes.tsx @@ -1,26 +1,82 @@ -import { useEffect, useState, useCallback } from 'react'; -import { api, type Dispute, type Correspondence } from '../lib/api'; +import { useEffect, useState, useCallback, useRef } from 'react'; +import { api, type Dispute, type Correspondence, type LegalDeadline } from '../lib/api'; import { Card } from '../components/ui/Card'; import { ActionButton } from '../components/ui/ActionButton'; import { ProgressDots } from '../components/ui/ProgressDots'; import { formatCurrency, formatDate } from '../lib/utils'; +import { useToast } from '../lib/toast'; +import { useSearchParams } from 'react-router-dom'; -const DISPUTE_STAGES = ['filed', 'response_pending', 'in_review', 'resolved']; +const DISPUTE_STAGES = [ + 'filed', + 'response_pending', + 'evidence_gathering', + 'in_review', + 'negotiation', + 'resolved', +] as const; -function disputeStageIndex(status: string): number { - const idx = DISPUTE_STAGES.indexOf(status); - return idx >= 0 ? idx : 0; +type DisputeStage = typeof DISPUTE_STAGES[number]; +type CorrespondenceDirection = 'inbound' | 'outbound'; +type CorrespondenceChannel = 'email' | 'phone' | 'mail' | 'portal' | 'in_person'; + +const STAGE_LABELS: Record = { + filed: 'Filed', + response_pending: 'Response Pending', + evidence_gathering: 'Evidence', + in_review: 'In Review', + negotiation: 'Negotiation', + resolved: 'Resolved', +}; + +function normalizeStage(dispute: Dispute): DisputeStage { + if (dispute.stage && DISPUTE_STAGES.includes(dispute.stage as DisputeStage)) { + return dispute.stage as DisputeStage; + } + if (dispute.status === 'resolved' || dispute.status === 'dismissed') return 'resolved'; + return 'filed'; +} + +function getNextStage(current: DisputeStage): DisputeStage { + const idx = DISPUTE_STAGES.indexOf(current); + if (idx < 0 || idx >= DISPUTE_STAGES.length - 1) return current; + return DISPUTE_STAGES[idx + 1]; } export function Disputes() { + const [searchParams] = useSearchParams(); const [disputes, setDisputes] = useState([]); const [error, setError] = useState(null); const [expandedId, setExpandedId] = useState(null); const [correspondenceList, setCorrespondenceList] = useState([]); const [documentList, setDocumentList] = useState<{ id: string; filename: string | null; doc_type: string; created_at: string }[]>([]); - const [activePanel, setActivePanel] = useState<'correspondence' | 'documents' | null>(null); - const [newCorrespondence, setNewCorrespondence] = useState({ direction: 'outbound', channel: 'email', subject: '', content: '' }); + const [deadlineList, setDeadlineList] = useState([]); + const [activePanel, setActivePanel] = useState<'correspondence' | 'documents' | 'deadlines' | null>(null); + const [newCorrespondence, setNewCorrespondence] = useState<{ + direction: CorrespondenceDirection; + channel: CorrespondenceChannel; + subject: string; + content: string; + }>({ direction: 'outbound', channel: 'email', subject: '', content: '' }); const [saving, setSaving] = useState(false); + const [creating, setCreating] = useState(false); + const [showCreateForm, setShowCreateForm] = useState(false); + const [advancingId, setAdvancingId] = useState(null); + const [panelError, setPanelError] = useState(null); + const [uploadingDoc, setUploadingDoc] = useState(false); + const [docFile, setDocFile] = useState(null); + const [newDispute, setNewDispute] = useState({ + title: '', + counterparty: '', + dispute_type: 'billing_error', + amount_at_stake: '', + priority: '5', + description: '', + next_action: '', + next_action_date: '', + }); + const toast = useToast(); + const autoExpandedRef = useRef(null); const reload = useCallback(() => { api.getDisputes('open').then(setDisputes).catch((e) => setError(e.message)); @@ -28,28 +84,85 @@ export function Disputes() { useEffect(() => { reload(); }, [reload]); - const [panelError, setPanelError] = useState(null); + useEffect(() => { + const expandId = searchParams.get('expand'); + if (!expandId || autoExpandedRef.current === expandId) return; + if (!disputes.some((d) => d.id === expandId)) return; + + autoExpandedRef.current = expandId; + setExpandedId(expandId); + setActivePanel('correspondence'); + setPanelError(null); + setDocFile(null); + + api.getDispute(expandId) + .then((detail) => setCorrespondenceList(detail.correspondence || [])) + .catch((e: unknown) => { + const msg = e instanceof Error ? e.message : 'Failed to load'; + setPanelError(`Unable to load correspondence: ${msg}`); + setCorrespondenceList([]); + }); + }, [disputes, searchParams]); - const togglePanel = async (disputeId: string, panel: 'correspondence' | 'documents') => { + const togglePanel = async (disputeId: string, panel: 'correspondence' | 'documents' | 'deadlines') => { if (expandedId === disputeId && activePanel === panel) { setExpandedId(null); setActivePanel(null); setPanelError(null); + setDocFile(null); return; } setExpandedId(disputeId); setActivePanel(panel); setPanelError(null); + setDocFile(null); try { const detail = await api.getDispute(disputeId); if (panel === 'correspondence') setCorrespondenceList(detail.correspondence || []); - else setDocumentList(detail.documents || []); + else if (panel === 'documents') setDocumentList(detail.documents || []); + else setDeadlineList(await api.getDisputeDeadlines(disputeId)); } catch (e: unknown) { const msg = e instanceof Error ? e.message : 'Failed to load'; - console.error(`[Disputes] ${panel} load failed for ${disputeId}:`, msg, e); setPanelError(`Unable to load ${panel}: ${msg}`); if (panel === 'correspondence') setCorrespondenceList([]); - else setDocumentList([]); + else if (panel === 'documents') setDocumentList([]); + else setDeadlineList([]); + } + }; + + const submitCreateDispute = async () => { + if (!newDispute.title.trim() || !newDispute.counterparty.trim() || !newDispute.dispute_type.trim()) return; + setCreating(true); + try { + await api.createDispute({ + title: newDispute.title.trim(), + counterparty: newDispute.counterparty.trim(), + dispute_type: newDispute.dispute_type.trim(), + amount_at_stake: newDispute.amount_at_stake ? parseFloat(newDispute.amount_at_stake) : undefined, + priority: parseInt(newDispute.priority, 10) || 5, + description: newDispute.description.trim() || undefined, + next_action: newDispute.next_action.trim() || undefined, + next_action_date: newDispute.next_action_date || undefined, + }); + toast.success('Dispute created', newDispute.title, { durationMs: 2200 }); + setShowCreateForm(false); + setNewDispute({ + title: '', + counterparty: '', + dispute_type: 'billing_error', + amount_at_stake: '', + priority: '5', + description: '', + next_action: '', + next_action_date: '', + }); + reload(); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'Failed to create dispute'; + setError(msg); + toast.error('Could not create dispute', msg); + } finally { + setCreating(false); } }; @@ -61,14 +174,55 @@ export function Disputes() { setNewCorrespondence({ direction: 'outbound', channel: 'email', subject: '', content: '' }); const detail = await api.getDispute(expandedId); setCorrespondenceList(detail.correspondence || []); + toast.success('Correspondence saved', 'Entry added to dispute timeline.', { durationMs: 2000 }); } catch (e: unknown) { - setError(e instanceof Error ? e.message : 'Failed to add correspondence'); + const msg = e instanceof Error ? e.message : 'Failed to add correspondence'; + setError(msg); + toast.error('Could not save correspondence', msg); } finally { setSaving(false); } }; - if (error) return

{error}

; + const advanceStage = async (dispute: Dispute) => { + const current = normalizeStage(dispute); + const next = getNextStage(current); + if (next === current) return; + + setAdvancingId(dispute.id); + try { + await api.updateDispute(dispute.id, { + stage: next, + ...(next === 'resolved' ? { status: 'resolved' } : {}), + }); + toast.success('Stage advanced', `${STAGE_LABELS[current]} → ${STAGE_LABELS[next]}`, { durationMs: 2200 }); + reload(); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'Failed to advance stage'; + toast.error('Could not advance stage', msg); + } finally { + setAdvancingId(null); + } + }; + + const uploadFromDisputeContext = async () => { + if (!expandedId || !docFile) return; + setUploadingDoc(true); + setPanelError(null); + try { + await api.uploadDocument(docFile, { linked_dispute_id: expandedId }); + const detail = await api.getDispute(expandedId); + setDocumentList(detail.documents || []); + toast.success('Document uploaded', docFile.name, { durationMs: 2200 }); + setDocFile(null); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'Upload failed'; + setPanelError(msg); + toast.error('Could not upload document', msg); + } finally { + setUploadingDoc(false); + } + }; const priorityUrgency = (p: number): 'red' | 'amber' | 'green' => { if (p <= 1) return 'red'; @@ -78,152 +232,307 @@ export function Disputes() { return (
-

Active Disputes

+
+

Active Disputes

+ setShowCreateForm((v) => !v)} + /> +
+ + {error && ( + +

{error}

+
+ )} + + {showCreateForm && ( + +

Create Dispute

+
+ setNewDispute((p) => ({ ...p, title: e.target.value }))} + placeholder="Title" + className="bg-card-bg border border-card-border rounded-lg px-3 py-2 text-sm text-card-text" + /> + setNewDispute((p) => ({ ...p, counterparty: e.target.value }))} + placeholder="Counterparty" + className="bg-card-bg border border-card-border rounded-lg px-3 py-2 text-sm text-card-text" + /> + setNewDispute((p) => ({ ...p, dispute_type: e.target.value }))} + placeholder="Dispute Type" + className="bg-card-bg border border-card-border rounded-lg px-3 py-2 text-sm text-card-text" + /> + setNewDispute((p) => ({ ...p, amount_at_stake: e.target.value }))} + placeholder="Amount At Stake" + className="bg-card-bg border border-card-border rounded-lg px-3 py-2 text-sm text-card-text" + /> + + setNewDispute((p) => ({ ...p, next_action_date: e.target.value }))} + className="bg-card-bg border border-card-border rounded-lg px-3 py-2 text-sm text-card-text" + /> +
+ setNewDispute((p) => ({ ...p, next_action: e.target.value }))} + placeholder="Next Action" + className="w-full mt-2 bg-card-bg border border-card-border rounded-lg px-3 py-2 text-sm text-card-text" + /> +