"""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