mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 10:08:46 +02:00
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:
20
backend/app/api/v1/endpoints/sync_status.py
Normal file
20
backend/app/api/v1/endpoints/sync_status.py
Normal 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")),
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
94
backend/app/services/sync_status.py
Normal file
94
backend/app/services/sync_status.py
Normal 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
|
||||
112
backend/tests/test_sync_and_export.py
Normal file
112
backend/tests/test_sync_and_export.py
Normal 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"
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/');
|
||||
|
||||
118
frontend/src/pages/SyncStatus.tsx
Normal file
118
frontend/src/pages/SyncStatus.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user