Sync Status: per-source ingestion health page with nav warning badge

The n8n flows fail invisibly from the app's perspective (review UX-17,
the top trust gap). /api/v1/sync-status derives last-received + cadence
thresholds for BAC, salary, municipal, pensions and exchange rate from
existing data — no n8n integration needed. New Sincronización page
shows per-source freshness and a hint to check n8n; the sidebar item
gets an amber dot when any source is stale. Verified live against the
dev DB (correctly flags its stale BAC data at 43 days).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-06-10 18:03:55 -06:00
parent 8b7f3dff40
commit 4ceb67564a
8 changed files with 392 additions and 3 deletions

View File

@@ -0,0 +1,20 @@
from fastapi import APIRouter, Depends
from sqlmodel import Session
from app.auth import get_current_user
from app.db import get_session
from app.services.sync_status import compute_sync_status
router = APIRouter(prefix="/sync-status", tags=["sync-status"])
@router.get("/")
def sync_status(
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
sources = compute_sync_status(session)
return {
"sources": sources,
"warnings": sum(1 for s in sources if s["status"] in ("warning", "never")),
}

View File

@@ -14,6 +14,7 @@ from app.api.v1.endpoints import (
salarios,
savings_accrual,
settings,
sync_status,
tokens,
transactions,
)
@@ -34,3 +35,4 @@ api_router.include_router(salarios.router)
api_router.include_router(pensions.router)
api_router.include_router(municipal_receipts.router)
api_router.include_router(savings_accrual.router)
api_router.include_router(sync_status.router)

View File

@@ -0,0 +1,94 @@
"""Per-source ingestion health, derived from existing data (review UX-17).
The n8n flows fail silently from the app's perspective — a parser break just
looks like missing data. This derives "when did we last receive something from
each source" plus a cadence threshold, so a stalled flow becomes visible
in-app without any n8n integration.
"""
from dataclasses import dataclass
from sqlmodel import Session, func, select
from app.models.models import (
Bank,
ExchangeRate,
MunicipalReceipt,
PensionSnapshot,
Transaction,
TransactionSource,
TransactionType,
)
from app.timeutil import utcnow
@dataclass
class SourceSpec:
key: str
label: str
warn_after_days: float
description: str
# Cadences: BAC emails arrive near-daily with normal card use; salary is
# biweekly/monthly; municipal and pension are monthly; rates refresh every 6h.
SOURCES: list[SourceSpec] = [
SourceSpec("bac_credit_card", "BAC Tarjeta de Crédito", 7,
"Correos de compra parseados por n8n"),
SourceSpec("salary", "Depósitos de Salario", 35,
"Correos de depósito parseados por n8n"),
SourceSpec("municipal", "Recibos Municipales", 40,
"PDF mensual vía n8n (cron)"),
SourceSpec("pensions", "Estados de Pensión", 40,
"PDFs diarios/mensuales vía n8n"),
SourceSpec("exchange_rate", "Tipo de Cambio", 1,
"Actualización automática cada 6 horas"),
]
def _last_received(session: Session, key: str):
if key == "bac_credit_card":
return session.exec(
select(func.max(Transaction.created_at)).where(
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.bank == Bank.BAC,
)
).one()
if key == "salary":
return session.exec(
select(func.max(Transaction.created_at)).where(
Transaction.transaction_type == TransactionType.SALARY
)
).one()
if key == "municipal":
return session.exec(select(func.max(MunicipalReceipt.created_at))).one()
if key == "pensions":
return session.exec(select(func.max(PensionSnapshot.created_at))).one()
if key == "exchange_rate":
return session.exec(select(func.max(ExchangeRate.fetched_at))).one()
raise ValueError(f"unknown sync source: {key}")
def compute_sync_status(session: Session) -> list[dict]:
now = utcnow()
out: list[dict] = []
for spec in SOURCES:
last = _last_received(session, spec.key)
if last is None:
status = "never"
age_days = None
else:
age_days = (now - last).total_seconds() / 86400
status = "ok" if age_days <= spec.warn_after_days else "warning"
out.append(
{
"key": spec.key,
"label": spec.label,
"description": spec.description,
"last_received": last.isoformat() if last else None,
"age_days": round(age_days, 1) if age_days is not None else None,
"warn_after_days": spec.warn_after_days,
"status": status,
}
)
return out

View File

@@ -0,0 +1,112 @@
"""Sync-status derivation (UX-17) and CSV export (ARCH-18)."""
import csv
import io
from datetime import datetime, timedelta
from decimal import Decimal
from app.models.models import (
Bank,
Category,
Currency,
ExchangeRate,
Transaction,
TransactionSource,
TransactionType,
)
from app.services.csv_export import build_transactions_csv
from app.services.sync_status import compute_sync_status
from app.timeutil import utcnow
def by_key(statuses: list[dict]) -> dict[str, dict]:
return {s["key"]: s for s in statuses}
class TestSyncStatus:
def test_empty_db_reports_never(self, session):
statuses = by_key(compute_sync_status(session))
assert len(statuses) == 5
assert all(s["status"] == "never" for s in statuses.values())
assert all(s["last_received"] is None for s in statuses.values())
def test_fresh_data_is_ok_and_stale_warns(self, session):
fresh = Transaction(
amount=Decimal("1"),
merchant="X",
date=utcnow(),
source=TransactionSource.CREDIT_CARD,
bank=Bank.BAC,
)
fresh.created_at = utcnow() - timedelta(days=1)
stale_salary = Transaction(
amount=Decimal("1"),
merchant="EMPLOYER",
date=utcnow(),
transaction_type=TransactionType.SALARY,
source=TransactionSource.TRANSFER,
)
stale_salary.created_at = utcnow() - timedelta(days=60)
session.add(fresh)
session.add(stale_salary)
session.add(
ExchangeRate(
date=utcnow(),
buy_rate=Decimal("500"),
sell_rate=Decimal("510"),
)
)
session.commit()
statuses = by_key(compute_sync_status(session))
assert statuses["bac_credit_card"]["status"] == "ok"
assert statuses["bac_credit_card"]["age_days"] == 1.0
assert statuses["salary"]["status"] == "warning"
assert statuses["exchange_rate"]["status"] == "ok"
assert statuses["municipal"]["status"] == "never"
class TestCsvExport:
def _seed(self, session):
cat = Category(name="Food")
session.add(cat)
session.commit()
session.refresh(cat)
session.add(
Transaction(
amount=Decimal("1500.50"),
merchant="SODA, LA \"BUENA\"", # exercises CSV quoting
date=datetime(2026, 4, 2),
category_id=cat.id,
currency=Currency.CRC,
)
)
session.add(
Transaction(
amount=Decimal("10"),
merchant="CASHTHING",
date=datetime(2026, 4, 3),
source=TransactionSource.CASH,
)
)
session.commit()
def test_full_export_with_quoting_and_category(self, session):
self._seed(session)
text = build_transactions_csv(session)
rows = list(csv.reader(io.StringIO(text)))
assert rows[0][:3] == ["id", "date", "merchant"]
assert len(rows) == 3 # header + 2
newest_first = rows[1]
assert newest_first[2] == "CASHTHING"
soda = rows[2]
assert soda[2] == 'SODA, LA "BUENA"' # round-trips through quoting
assert soda[4] == "1500.50"
assert soda[9] == "Food"
def test_source_filter(self, session):
self._seed(session)
text = build_transactions_csv(session, source=TransactionSource.CASH)
rows = list(csv.reader(io.StringIO(text)))
assert len(rows) == 2
assert rows[1][2] == "CASHTHING"

View File

@@ -23,6 +23,7 @@ import Salarios from "./pages/Salarios";
import Pensions from "./pages/Pensions";
import Proyecciones from "./pages/Proyecciones";
import ServiciosMunicipales from "./pages/ServiciosMunicipales";
import SyncStatus from "./pages/SyncStatus";
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuth();
@@ -56,6 +57,7 @@ function AppRoutes() {
<Route path="/salarios" element={<Salarios />} />
<Route path="/pensions" element={<Pensions />} />
<Route path="/servicios-municipales" element={<ServiciosMunicipales />} />
<Route path="/sync" element={<SyncStatus />} />
<Route path="/transactions" element={<Navigate to="/budget" replace />} />
<Route path="/transfers" element={<Navigate to="/budget" replace />} />
</Route>

View File

@@ -11,12 +11,16 @@ import {
TrendingUp,
Wallet,
Menu,
RefreshCw,
Sun,
Moon,
Eye,
EyeOff,
type LucideIcon,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { getSyncStatus } from "@/lib/api";
import { useTheme } from "@/contexts/theme-context";
import { usePrivacy } from "@/contexts/privacy-context";
import { useAuth } from "@/AuthContext";
@@ -39,7 +43,10 @@ interface NavSection {
const navSections: NavSection[] = [
{
label: "General",
items: [{ to: "/asistente", icon: Sparkles, label: "Asistente" }],
items: [
{ to: "/asistente", icon: Sparkles, label: "Asistente" },
{ to: "/sync", icon: RefreshCw, label: "Sincronización" },
],
},
{
label: "Finanzas",
@@ -64,6 +71,14 @@ function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
const isActive = (to: string) =>
pathname === to || pathname.startsWith(`${to}/`);
// Surface stalled ingestion sources as a dot on the Sincronización item.
const syncQ = useQuery({
queryKey: ['sync-status'],
queryFn: () => getSyncStatus().then((r) => r.data),
staleTime: 5 * 60_000,
});
const syncWarnings = syncQ.data?.warnings ?? 0;
return (
<nav className="flex flex-col gap-0.5 px-3">
{navSections.map((section) => (
@@ -85,6 +100,13 @@ function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
>
<Icon className="w-4 h-4" />
{label}
{to === "/sync" && syncWarnings > 0 && (
<span
className="ml-auto w-2 h-2 rounded-full bg-amber-500"
title={`${syncWarnings} fuente(s) atrasada(s)`}
aria-label={`${syncWarnings} fuentes de datos atrasadas`}
/>
)}
</Link>
))}
</div>
@@ -129,10 +151,10 @@ export default function Layout() {
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" onClick={togglePrivacy} title="Toggle privacy mode" aria-label="Toggle privacy mode">
<Button variant="ghost" size="icon" onClick={togglePrivacy} title="Toggle privacy mode" aria-label="Toggle privacy mode" aria-pressed={privacyMode}>
{privacyMode ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
<Button variant="ghost" size="icon" onClick={toggleTheme} title="Toggle theme" aria-label="Toggle theme">
<Button variant="ghost" size="icon" onClick={toggleTheme} title="Toggle theme" aria-label="Toggle theme" aria-pressed={theme === "dark"}>
{theme === "dark" ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</Button>
<Button

View File

@@ -485,3 +485,22 @@ export const getWaterConsumption = (months?: number) =>
api.get<WaterMeterReading[]>("/municipal-receipts/water-consumption", {
params: months ? { months } : undefined,
});
// --- Sync Status ---
export interface SyncSource {
key: string;
label: string;
description: string;
last_received: string | null;
age_days: number | null;
warn_after_days: number;
status: 'ok' | 'warning' | 'never';
}
export interface SyncStatusResponse {
sources: SyncSource[];
warnings: number;
}
export const getSyncStatus = () => api.get<SyncStatusResponse>('/sync-status/');

View File

@@ -0,0 +1,118 @@
import { useQuery } from '@tanstack/react-query';
import {
AlertTriangle,
CheckCircle2,
HelpCircle,
RefreshCw,
} from 'lucide-react';
import { getSyncStatus, type SyncSource } from '@/lib/api';
import { formatRelativeAge } from '@/lib/dates';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import ErrorState from '@/components/ErrorState';
import { Skeleton } from '@/components/ui/skeleton';
function StatusIcon({ status }: { status: SyncSource['status'] }) {
if (status === 'ok')
return <CheckCircle2 className="w-5 h-5 text-emerald-500" aria-hidden="true" />;
if (status === 'warning')
return <AlertTriangle className="w-5 h-5 text-amber-500" aria-hidden="true" />;
return <HelpCircle className="w-5 h-5 text-muted-foreground" aria-hidden="true" />;
}
function statusLabel(s: SyncSource): string {
if (s.status === 'never') return 'Nunca recibido';
if (s.status === 'warning')
return `Sin datos nuevos (esperado cada ~${s.warn_after_days} días)`;
return 'Al día';
}
export default function SyncStatus() {
const query = useQuery({
queryKey: ['sync-status'],
queryFn: () => getSyncStatus().then((r) => r.data),
staleTime: 5 * 60_000,
});
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<RefreshCw className="w-6 h-6 text-primary" aria-hidden="true" />
<div>
<h1 className="text-2xl font-bold tracking-tight">Sincronización</h1>
<p className="text-sm text-muted-foreground">
Estado de los flujos automáticos de datos (n8n y tipo de cambio)
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => query.refetch()}
disabled={query.isFetching}
>
<RefreshCw
className={query.isFetching ? 'w-4 h-4 mr-2 animate-spin' : 'w-4 h-4 mr-2'}
aria-hidden="true"
/>
Actualizar
</Button>
</div>
{query.isError ? (
<ErrorState
message="No se pudo consultar el estado de sincronización"
onRetry={() => query.refetch()}
/>
) : query.isPending ? (
<div className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</div>
) : (
<div className="space-y-3">
{query.data.sources.map((s) => (
<Card
key={s.key}
className={
s.status !== 'ok' ? 'border-amber-500/40' : undefined
}
>
<CardContent className="p-4 flex items-center gap-4">
<StatusIcon status={s.status} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium">{s.label}</span>
{s.status !== 'ok' && (
<Badge variant="destructive">Atención</Badge>
)}
</div>
<p className="text-sm text-muted-foreground truncate">
{s.description}
</p>
</div>
<div className="text-right shrink-0">
<p className="text-sm font-medium">{statusLabel(s)}</p>
<p className="text-xs text-muted-foreground">
{s.last_received
? `Último: ${formatRelativeAge(s.last_received)}`
: '—'}
</p>
</div>
</CardContent>
</Card>
))}
<p className="text-xs text-muted-foreground">
Si una fuente aparece atrasada, revisá el flujo correspondiente en
n8n un cambio de formato en los correos del banco es la causa más
común.
</p>
</div>
)}
</div>
);
}