From 4ceb67564aed1035ee415a44523cafe7bc73fa97 Mon Sep 17 00:00:00 2001 From: Carlos Escalante Date: Wed, 10 Jun 2026 18:03:55 -0600 Subject: [PATCH] Sync Status: per-source ingestion health page with nav warning badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/api/v1/endpoints/sync_status.py | 20 ++++ backend/app/api/v1/router.py | 2 + backend/app/services/sync_status.py | 94 ++++++++++++++++ backend/tests/test_sync_and_export.py | 112 +++++++++++++++++++ frontend/src/App.tsx | 2 + frontend/src/components/Layout.tsx | 28 ++++- frontend/src/lib/api.ts | 19 ++++ frontend/src/pages/SyncStatus.tsx | 118 ++++++++++++++++++++ 8 files changed, 392 insertions(+), 3 deletions(-) create mode 100644 backend/app/api/v1/endpoints/sync_status.py create mode 100644 backend/app/services/sync_status.py create mode 100644 backend/tests/test_sync_and_export.py create mode 100644 frontend/src/pages/SyncStatus.tsx diff --git a/backend/app/api/v1/endpoints/sync_status.py b/backend/app/api/v1/endpoints/sync_status.py new file mode 100644 index 0000000..6413081 --- /dev/null +++ b/backend/app/api/v1/endpoints/sync_status.py @@ -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")), + } diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 8427c50..196a200 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -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) diff --git a/backend/app/services/sync_status.py b/backend/app/services/sync_status.py new file mode 100644 index 0000000..c909429 --- /dev/null +++ b/backend/app/services/sync_status.py @@ -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 diff --git a/backend/tests/test_sync_and_export.py b/backend/tests/test_sync_and_export.py new file mode 100644 index 0000000..11cdc14 --- /dev/null +++ b/backend/tests/test_sync_and_export.py @@ -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" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9ee13d0..707ef76 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 6e6ce3c..1477a0d 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -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 (