From eccfd53e0b6197cc11c9e3f703d3744326d02e8a Mon Sep 17 00:00:00 2001 From: Carlos Escalante Date: Sat, 28 Mar 2026 22:24:42 -0600 Subject: [PATCH] Add pension PDF upload, parsing, and fund summary API Backend: parse BAC pension statement PDFs (VOL, ROP, FCL) via pdftotext, store snapshots with duplicate detection, reject credit card statements. Endpoints: POST /upload, GET /snapshots, GET /fund-summary. Frontend: wire up drag-and-drop upload, load real balances and rendimientos from API, show upload results with error/duplicate feedback. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/Dockerfile | 1 + backend/Dockerfile.prod | 1 + backend/app/api/v1/endpoints/pensions.py | 121 +++++++++++ backend/app/api/v1/router.py | 2 + backend/app/models/models.py | 37 +++- backend/app/services/pension_pdf.py | 225 ++++++++++++++++++++ frontend/src/api.ts | 40 ++++ frontend/src/pages/Pensions.tsx | 260 ++++++++++++++++++----- 8 files changed, 631 insertions(+), 56 deletions(-) create mode 100644 backend/app/api/v1/endpoints/pensions.py create mode 100644 backend/app/services/pension_pdf.py diff --git a/backend/Dockerfile b/backend/Dockerfile index fecce0d..bafad53 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,5 +1,6 @@ FROM python:3.11-slim WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends poppler-utils && rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . diff --git a/backend/Dockerfile.prod b/backend/Dockerfile.prod index fc2e612..1fa51b8 100644 --- a/backend/Dockerfile.prod +++ b/backend/Dockerfile.prod @@ -1,5 +1,6 @@ FROM python:3.11-slim WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends poppler-utils && rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . diff --git a/backend/app/api/v1/endpoints/pensions.py b/backend/app/api/v1/endpoints/pensions.py new file mode 100644 index 0000000..785a0a8 --- /dev/null +++ b/backend/app/api/v1/endpoints/pensions.py @@ -0,0 +1,121 @@ +from fastapi import APIRouter, Depends, UploadFile +from pydantic import BaseModel +from sqlmodel import Session, select + +from app.auth import get_current_user +from app.db import get_session +from app.models.models import Bank, PensionSnapshot, PensionSnapshotRead +from app.services.pension_pdf import parse_pension_pdf + +router = APIRouter(prefix="/pensions", tags=["pensions"]) + + +class PensionUploadResult(BaseModel): + imported: int + duplicates: int + errors: list[str] + snapshots: list[PensionSnapshotRead] + + +@router.post("/upload", response_model=PensionUploadResult) +async def upload_pension_pdfs( + files: list[UploadFile], + session: Session = Depends(get_session), + _user: str = Depends(get_current_user), +): + imported = 0 + duplicates = 0 + errors: list[str] = [] + created: list[PensionSnapshot] = [] + + for file in files: + filename = file.filename or "unknown.pdf" + try: + pdf_bytes = await file.read() + fund_snapshots = parse_pension_pdf(pdf_bytes, filename) + except ValueError as e: + errors.append(str(e)) + continue + except Exception as e: + errors.append(f"{filename}: {e}") + continue + + for snap in fund_snapshots: + existing = session.exec( + select(PensionSnapshot).where( + PensionSnapshot.fund == Bank(snap.fund), + PensionSnapshot.period_start == snap.period_start, + PensionSnapshot.period_end == snap.period_end, + ) + ).first() + if existing: + duplicates += 1 + continue + + row = PensionSnapshot( + fund=Bank(snap.fund), + contract_number=snap.contract_number, + period_start=snap.period_start, + period_end=snap.period_end, + saldo_anterior=snap.saldo_anterior, + aportes=snap.aportes, + rendimientos=snap.rendimientos, + retiros=snap.retiros, + traslados=snap.traslados, + comision=snap.comision, + correccion=snap.correccion, + bonificacion=snap.bonificacion, + saldo_final=snap.saldo_final, + source_filename=filename, + ) + session.add(row) + created.append(row) + imported += 1 + + if imported > 0: + session.commit() + for row in created: + session.refresh(row) + + return PensionUploadResult( + imported=imported, + duplicates=duplicates, + errors=errors, + snapshots=[PensionSnapshotRead.model_validate(r) for r in created], + ) + + +@router.get("/snapshots", response_model=list[PensionSnapshotRead]) +def get_snapshots( + session: Session = Depends(get_session), + _user: str = Depends(get_current_user), +): + rows = session.exec( + select(PensionSnapshot).order_by( + PensionSnapshot.period_end.desc(), # type: ignore[union-attr] + PensionSnapshot.fund, + ) + ).all() + return rows + + +@router.get("/fund-summary", response_model=list[PensionSnapshotRead]) +def get_fund_summary( + session: Session = Depends(get_session), + _user: str = Depends(get_current_user), +): + """Return the latest snapshot per fund (by most recent period_end).""" + all_rows = session.exec( + select(PensionSnapshot).order_by( + PensionSnapshot.period_end.desc(), # type: ignore[union-attr] + ) + ).all() + + seen: set[str] = set() + latest: list[PensionSnapshot] = [] + for row in all_rows: + if row.fund.value not in seen: + seen.add(row.fund.value) + latest.append(row) + + return latest diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 987bcb4..6db6e2e 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -9,6 +9,7 @@ from app.api.v1.endpoints import ( exchange_rate, import_transactions, notifications, + pensions, salarios, settings, tokens, @@ -28,3 +29,4 @@ api_router.include_router(settings.router) api_router.include_router(budget.router) api_router.include_router(notifications.router) api_router.include_router(salarios.router) +api_router.include_router(pensions.router) diff --git a/backend/app/models/models.py b/backend/app/models/models.py index d2355a7..f830679 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -1,8 +1,8 @@ import enum -from datetime import datetime +from datetime import date, datetime from typing import Optional -from sqlalchemy import JSON, Column +from sqlalchemy import JSON, Column, UniqueConstraint from sqlmodel import Field, Relationship, SQLModel @@ -300,3 +300,36 @@ class PushSubscription(SQLModel, table=True): class PushSubscriptionCreate(SQLModel): endpoint: str keys: dict # {"p256dh": "...", "auth": "..."} + + +# --- Pension Snapshot --- + + +class PensionSnapshotBase(SQLModel): + fund: Bank + contract_number: str + period_start: date + period_end: date + saldo_anterior: float + aportes: float + rendimientos: float + retiros: float + traslados: float + comision: float + correccion: float + bonificacion: float + saldo_final: float + source_filename: str + + +class PensionSnapshot(PensionSnapshotBase, table=True): + __table_args__ = ( + UniqueConstraint("fund", "period_start", "period_end"), + ) + id: Optional[int] = Field(default=None, primary_key=True) + created_at: datetime = Field(default_factory=datetime.utcnow) + + +class PensionSnapshotRead(PensionSnapshotBase): + id: int + created_at: datetime diff --git a/backend/app/services/pension_pdf.py b/backend/app/services/pension_pdf.py new file mode 100644 index 0000000..81c936c --- /dev/null +++ b/backend/app/services/pension_pdf.py @@ -0,0 +1,225 @@ +"""Parse BAC San José Pensiones PDF statements into structured fund snapshots.""" + +import re +import shutil +import subprocess +import tempfile +from dataclasses import dataclass +from datetime import date + + +@dataclass +class FundSnapshot: + fund: str # "ROP", "FCL", or "VOL" + contract_number: str + period_start: date + period_end: date + saldo_anterior: float + aportes: float + rendimientos: float + retiros: float + traslados: float + comision: float + correccion: float + bonificacion: float + saldo_final: float + + +def _find_pdftotext() -> str: + """Find pdftotext binary, checking common install paths.""" + import os + + cmd = shutil.which("pdftotext") + if cmd: + return cmd + for path in ["/opt/homebrew/bin/pdftotext", "/usr/bin/pdftotext", "/usr/local/bin/pdftotext"]: + if os.path.isfile(path): + return path + raise FileNotFoundError("pdftotext not found — install poppler-utils") + + +def extract_text(pdf_bytes: bytes) -> str: + pdftotext_bin = _find_pdftotext() + with tempfile.NamedTemporaryFile(suffix=".pdf") as f: + f.write(pdf_bytes) + f.flush() + result = subprocess.run( + [pdftotext_bin, "-layout", f.name, "-"], + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode != 0: + raise ValueError(f"pdftotext failed: {result.stderr.strip()}") + return result.stdout + + +def detect_type(text: str) -> str: + """Return 'VOL', 'ROP_FCL', or 'UNKNOWN'.""" + if any(kw in text for kw in ("MARCA DE TARJETA", "ESTADO DE CUENTA", "PAGO MÍNIMO")): + return "CREDIT_CARD" + if "FONDO C VOLUNTARIO" in text: + return "VOL" + if "RÉGIMEN OBLIGATORIO" in text or ("ROP" in text and "FCL" in text): + return "ROP_FCL" + return "UNKNOWN" + + +def _parse_amount(s: str) -> float: + """Parse '17,819,176.79' or '-12,693.13' into float.""" + cleaned = s.replace(",", "") + return float(cleaned) + + +def _find_amounts(line: str) -> list[float]: + """Extract all ¢-prefixed amounts from a line.""" + return [_parse_amount(m) for m in re.findall(r"¢\s*(-?[\d,]+\.\d{2})", line)] + + +def _parse_period(text: str) -> tuple[date, date]: + m = re.search(r"DEL\s+(\d{2}/\d{2}/\d{4})\s+AL\s+(\d{2}/\d{2}/\d{4})", text) + if not m: + raise ValueError("Could not find period dates (DEL ... AL ...)") + start = date(int(m.group(1)[6:]), int(m.group(1)[3:5]), int(m.group(1)[:2])) + end = date(int(m.group(2)[6:]), int(m.group(2)[3:5]), int(m.group(2)[:2])) + return start, end + + +def _extract_summary_value(text: str, label: str) -> list[float]: + """Find a summary line by label and return all ¢ amounts on that line.""" + pattern = re.compile(re.escape(label) + r".*", re.IGNORECASE) + for line in text.split("\n"): + if pattern.search(line): + amounts = _find_amounts(line) + if amounts: + return amounts + return [] + + +_SUMMARY_FIELDS = [ + ("Saldo Anterior", "saldo_anterior"), + ("Aportes", "aportes"), + ("Rendimientos", "rendimientos"), + ("Retiros", "retiros"), + ("Traslados", "traslados"), + ("Comisión de Administración", "comision"), + ("Corrección de Imputaciones", "correccion"), + ("Bonificación", "bonificacion"), +] + + +def _find_final_balance(text: str, after_label: str = "Bonificación") -> list[float]: + """Find the standalone balance line after the last summary field. + + After Bonificación (or Corrección for ROP+FCL), there's a line with just + the final balance amount(s) and no label. + """ + lines = text.split("\n") + found_label = False + for line in lines: + if after_label in line: + found_label = True + continue + if found_label: + amounts = _find_amounts(line) + if amounts: + return amounts + return [] + + +def parse_vol(text: str) -> list[FundSnapshot]: + period_start, period_end = _parse_period(text) + + # Contract number + m = re.search(r"N°\s*Contrato:\s*(\S+)", text) + contract = m.group(1) if m else "" + + data: dict[str, float] = {} + for label, field in _SUMMARY_FIELDS: + amounts = _extract_summary_value(text, label) + data[field] = amounts[0] if amounts else 0.0 + + finals = _find_final_balance(text, "Bonificación") + if not finals: + # Fallback: look after Corrección + finals = _find_final_balance(text, "Corrección de Imputaciones") + saldo_final = finals[0] if finals else 0.0 + + return [ + FundSnapshot( + fund="VOL", + contract_number=contract, + period_start=period_start, + period_end=period_end, + saldo_final=saldo_final, + **data, + ) + ] + + +def parse_rop_fcl(text: str) -> list[FundSnapshot]: + period_start, period_end = _parse_period(text) + + # Contract numbers + m_rop = re.search(r"N°\s*Contrato\s*ROP:\s*(\S+)", text) + m_fcl = re.search(r"N°\s*Contrato\s*FCL:\s*(\S+)", text) + contract_rop = m_rop.group(1) if m_rop else "" + contract_fcl = m_fcl.group(1) if m_fcl else "" + + rop_data: dict[str, float] = {} + fcl_data: dict[str, float] = {} + + for label, field in _SUMMARY_FIELDS: + amounts = _extract_summary_value(text, label) + if len(amounts) >= 2: + rop_data[field] = amounts[0] + fcl_data[field] = amounts[1] + elif len(amounts) == 1: + rop_data[field] = amounts[0] + fcl_data[field] = 0.0 + else: + rop_data[field] = 0.0 + fcl_data[field] = 0.0 + + # Final balance line (after Corrección since ROP+FCL has no Bonificación) + finals = _find_final_balance(text, "Corrección de Imputaciones") + rop_final = finals[0] if len(finals) >= 1 else 0.0 + fcl_final = finals[1] if len(finals) >= 2 else 0.0 + + return [ + FundSnapshot( + fund="ROP", + contract_number=contract_rop, + period_start=period_start, + period_end=period_end, + saldo_final=rop_final, + **rop_data, + ), + FundSnapshot( + fund="FCL", + contract_number=contract_fcl, + period_start=period_start, + period_end=period_end, + saldo_final=fcl_final, + **fcl_data, + ), + ] + + +def parse_pension_pdf(pdf_bytes: bytes, filename: str = "") -> list[FundSnapshot]: + """Parse a pension PDF and return fund snapshots. + + Raises ValueError for credit card statements or unrecognized formats. + """ + text = extract_text(pdf_bytes) + doc_type = detect_type(text) + + if doc_type == "CREDIT_CARD": + raise ValueError(f"'{filename}' is a credit card statement, not a pension extract") + if doc_type == "UNKNOWN": + raise ValueError(f"'{filename}' is not a recognized BAC pension statement") + + if doc_type == "VOL": + return parse_vol(text) + else: + return parse_rop_fcl(text) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 2cc5e39..b6e5568 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -238,3 +238,43 @@ export const getSalarios = (params?: { limit?: number; offset?: number }) => api.get('/salarios/', { params }); export const getSalariosSummary = () => api.get('/salarios/summary'); + +// --- Pensions --- + +export interface PensionSnapshot { + id: number; + fund: string; + contract_number: string; + period_start: string; + period_end: string; + saldo_anterior: number; + aportes: number; + rendimientos: number; + retiros: number; + traslados: number; + comision: number; + correccion: number; + bonificacion: number; + saldo_final: number; + source_filename: string; + created_at: string; +} + +export interface PensionUploadResult { + imported: number; + duplicates: number; + errors: string[]; + snapshots: PensionSnapshot[]; +} + +export const uploadPensionPDFs = (files: File[]) => { + const form = new FormData(); + files.forEach((f) => form.append('files', f)); + return api.post('/pensions/upload', form); +}; + +export const getPensionSnapshots = () => + api.get('/pensions/snapshots'); + +export const getPensionFundSummary = () => + api.get('/pensions/fund-summary'); diff --git a/frontend/src/pages/Pensions.tsx b/frontend/src/pages/Pensions.tsx index 7efa623..2b3b39d 100644 --- a/frontend/src/pages/Pensions.tsx +++ b/frontend/src/pages/Pensions.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useCallback, useRef } from 'react'; +import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { LineChart, Line, @@ -18,6 +18,9 @@ import { Percent, Banknote, FileText, + Loader2, + CheckCircle2, + AlertTriangle, } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -25,6 +28,12 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Badge } from '@/components/ui/badge'; import { Separator } from '@/components/ui/separator'; +import { + uploadPensionPDFs, + getPensionFundSummary, + type PensionSnapshot, + type PensionUploadResult, +} from '@/api'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -65,7 +74,7 @@ const CURRENT_AGE = 30; const FUND_KEYS: FundKey[] = ['FCL', 'ROP', 'MPAT', 'MEMP', 'VOL']; -const FUNDS: Record = { +const FUNDS_DEFAULT: Record = { FCL: { key: 'FCL', name: 'FCL', @@ -143,24 +152,15 @@ const formatCRC = (amount: number): string => maximumFractionDigits: 0, }).format(amount); -function generateChartData(): ChartDataPoint[] { - // Work backwards from current balances (end of Mar 2026) to derive 12 months of history. - // history[11] = Mar 26 (current), history[0] = Apr 25. - // Funds that went live recently will show 0 for the months before they started. - // - // Reverse formulas: - // Interest fund: prev = (curr - contribution) / (1 + monthlyRate) - // Dividend month: prev_MPAT/MEMP = curr / 1.03 - contribution (Mar is index 11) - // Dividend other: prev_MPAT/MEMP = curr - contribution - +function generateChartData(funds: Record): ChartDataPoint[] { const history: ChartDataPoint[] = new Array(12); let bal = { - FCL: FUNDS.FCL.startBalance, - ROP: FUNDS.ROP.startBalance, - MPAT: FUNDS.MPAT.startBalance, - MEMP: FUNDS.MEMP.startBalance, - VOL: FUNDS.VOL.startBalance, + FCL: funds.FCL.startBalance, + ROP: funds.ROP.startBalance, + MPAT: funds.MPAT.startBalance, + MEMP: funds.MEMP.startBalance, + VOL: funds.VOL.startBalance, }; history[11] = { @@ -173,7 +173,6 @@ function generateChartData(): ChartDataPoint[] { }; for (let i = 10; i >= 0; i--) { - // i === 10 reverses the Feb→Mar step, which included the annual dividend for MPAT/MEMP const undoDividend = i === 10; bal = { FCL: Math.max(0, (bal.FCL - 150_000) / (1 + 0.075 / 12)), @@ -223,6 +222,20 @@ function calcProjection( ); } +/** Merge API snapshots into the default fund definitions. */ +function applySnapshots( + snapshots: PensionSnapshot[], +): Record { + const funds = { ...FUNDS_DEFAULT }; + for (const snap of snapshots) { + const key = snap.fund as FundKey; + if (key in funds) { + funds[key] = { ...funds[key], startBalance: Math.round(snap.saldo_final) }; + } + } + return funds; +} + // ─── Chart Tooltip ──────────────────────────────────────────────────────────── function ChartTooltipContent({ @@ -257,6 +270,7 @@ function ChartTooltipContent({ // ─── Main Component ─────────────────────────────────────────────────────────── export default function Pensions() { + const [fundSummary, setFundSummary] = useState([]); const [visibleFunds, setVisibleFunds] = useState>(new Set(FUND_KEYS)); const [projections, setProjections] = useState>({ FCL: { contribution: 150_000, rate: 7.5, targetAge: 35 }, @@ -267,28 +281,57 @@ export default function Pensions() { }); const [uploadedFiles, setUploadedFiles] = useState([]); const [isDragging, setIsDragging] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const [uploadResult, setUploadResult] = useState(null); const fileInputRef = useRef(null); - const chartData = useMemo(() => generateChartData(), []); + const loadFundSummary = useCallback(async () => { + try { + const { data } = await getPensionFundSummary(); + setFundSummary(data); + } catch { + // API not available or no data yet — use defaults + } + }, []); + + useEffect(() => { + loadFundSummary(); + }, [loadFundSummary]); + + const FUNDS = useMemo(() => applySnapshots(fundSummary), [fundSummary]); + + // Build a map of fund -> latest snapshot for rendimientos display + const snapshotByFund = useMemo(() => { + const map: Partial> = {}; + for (const snap of fundSummary) { + map[snap.fund as FundKey] = snap; + } + return map; + }, [fundSummary]); + + const chartData = useMemo(() => generateChartData(FUNDS), [FUNDS]); const roiEarned = useMemo(() => { return FUND_KEYS.reduce>((acc, key) => { - const fund = FUNDS[key]; - if (fund.isDividend) { - // Dividend earned in March = balance after dividend − balance before dividend - // history[11] = (history[10] + contribution) × 1.03 - // → dividend = history[11] − history[10] − contribution - acc[key] = Math.max(0, Math.round( - chartData[11][key] - chartData[10][key] - fund.monthlyContribution, - )); + const snap = snapshotByFund[key]; + if (snap) { + // Use real rendimientos from the API + acc[key] = Math.round(snap.rendimientos); } else { - // Approximate interest earned = currentBalance × annualRate × (activeMonths / 12) - const activeMonths = chartData.filter((d) => d[key] > 0).length; - acc[key] = Math.round(fund.startBalance * (fund.annualRate / 100) * (activeMonths / 12)); + // Fallback: approximate from hardcoded data + const fund = FUNDS[key]; + if (fund.isDividend) { + acc[key] = Math.max(0, Math.round( + chartData[11][key] - chartData[10][key] - fund.monthlyContribution, + )); + } else { + const activeMonths = chartData.filter((d) => d[key] > 0).length; + acc[key] = Math.round(fund.startBalance * (fund.annualRate / 100) * (activeMonths / 12)); + } } return acc; }, {} as Record); - }, [chartData]); + }, [FUNDS, chartData, snapshotByFund]); const toggleFund = (key: FundKey) => { setVisibleFunds((prev) => { @@ -314,8 +357,31 @@ export default function Pensions() { if (!files) return; const pdfs = Array.from(files).filter((f) => f.type === 'application/pdf'); setUploadedFiles((prev) => [...prev, ...pdfs]); + setUploadResult(null); }, []); + const handleUpload = async () => { + if (uploadedFiles.length === 0) return; + setIsUploading(true); + setUploadResult(null); + try { + const { data } = await uploadPensionPDFs(uploadedFiles); + setUploadResult(data); + setUploadedFiles([]); + // Refresh fund summary with new data + await loadFundSummary(); + } catch (err) { + setUploadResult({ + imported: 0, + duplicates: 0, + errors: [err instanceof Error ? err.message : 'Error al subir archivos'], + snapshots: [], + }); + } finally { + setIsUploading(false); + } + }; + const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); @@ -366,6 +432,7 @@ export default function Pensions() {
{FUND_KEYS.map((key) => { const fund = FUNDS[key]; + const snap = snapshotByFund[key]; return ( {formatCRC(fund.startBalance)}

+ {snap && ( +

+ al {new Date(snap.period_end).toLocaleDateString('es-CR')} +

+ )}
-
-
- Aporte mensual - - {formatCRC(fund.monthlyContribution)} - + {snap ? ( +
+
+ Aportes + + {formatCRC(snap.aportes)} + +
+
+ Rendimientos + + {formatCRC(snap.rendimientos)} + +
+
+ Comisión + + {formatCRC(snap.comision)} + +
-
- Tasa anual - {fund.annualRate}% + ) : ( +
+
+ Aporte mensual + + {formatCRC(fund.monthlyContribution)} + +
+
+ Tasa anual + {fund.annualRate}% +
-
+ )}
@@ -501,11 +596,12 @@ export default function Pensions() {

- Rendimiento — Últimos 12 meses + Rendimiento — Último Periodo

{FUND_KEYS.map((key) => { const fund = FUNDS[key]; + const snap = snapshotByFund[key]; const earned = roiEarned[key]; return ( @@ -517,9 +613,17 @@ export default function Pensions() { /> {fund.name}
-

- {fund.isDividend ? `Dividendos ${fund.annualRate}%` : `${fund.annualRate}% anual`} -

+ {snap ? ( +

+ {new Date(snap.period_start).toLocaleDateString('es-CR', { month: 'short', year: '2-digit' })} + {' — '} + {new Date(snap.period_end).toLocaleDateString('es-CR', { month: 'short', year: '2-digit' })} +

+ ) : ( +

+ {fund.isDividend ? `Dividendos ${fund.annualRate}%` : `${fund.annualRate}% anual`} +

+ )}

+{formatCRC(earned)}

@@ -698,16 +802,64 @@ export default function Pensions() {
)} - {/* Submit with "Próximamente" tooltip */} -
- - - Próximamente - -
+ )} + {isUploading ? 'Procesando...' : 'Subir PDFs'} + + + {/* Upload result */} + {uploadResult && ( +
0 && uploadResult.imported === 0 + ? 'border-destructive/50 bg-destructive/5' + : 'border-emerald-500/50 bg-emerald-500/5', + ].join(' ')}> +
+ {uploadResult.imported > 0 ? ( + + ) : ( + + )} + + {uploadResult.imported > 0 + ? `${uploadResult.imported} ${uploadResult.imported === 1 ? 'extracto importado' : 'extractos importados'}` + : 'Ningún extracto nuevo importado'} + +
+ {uploadResult.duplicates > 0 && ( +

+ {uploadResult.duplicates} {uploadResult.duplicates === 1 ? 'duplicado omitido' : 'duplicados omitidos'} +

+ )} + {uploadResult.errors.map((err, i) => ( +

{err}

+ ))} + {uploadResult.snapshots.length > 0 && ( +
+ {uploadResult.snapshots.map((snap) => ( +
+ + {snap.fund} · {new Date(snap.period_start).toLocaleDateString('es-CR', { month: 'short', year: '2-digit' })} + {' — '} + {new Date(snap.period_end).toLocaleDateString('es-CR', { month: 'short', year: '2-digit' })} + + {formatCRC(snap.saldo_final)} +
+ ))} +
+ )} +
+ )}