Compare commits

..

2 Commits

Author SHA1 Message Date
Carlos Escalante
eccfd53e0b Add pension PDF upload, parsing, and fund summary API
All checks were successful
Deploy to VPS / deploy (push) Successful in 48s
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) <noreply@anthropic.com>
2026-03-28 22:24:42 -06:00
Carlos Escalante
1b90f0c70a Add Pensions page with fund overview, growth chart, and projections
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 18:13:52 -06:00
10 changed files with 1297 additions and 2 deletions

View File

@@ -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 . .

View File

@@ -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 . .

View File

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

View File

@@ -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)

View File

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

View File

@@ -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"\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"\s*Contrato\s*ROP:\s*(\S+)", text)
m_fcl = re.search(r"\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)

View File

@@ -7,6 +7,7 @@ import Dashboard from './pages/Dashboard';
import Budget from './pages/Budget';
import Analytics from './pages/Analytics';
import Salarios from './pages/Salarios';
import Pensions from './pages/Pensions';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuth();
@@ -33,6 +34,7 @@ function AppRoutes() {
<Route path="/budget" element={<Budget />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/salarios" element={<Salarios />} />
<Route path="/pensions" element={<Pensions />} />
{/* Redirect old routes */}
<Route path="/transactions" element={<Navigate to="/budget" replace />} />
<Route path="/transfers" element={<Navigate to="/budget" replace />} />

View File

@@ -238,3 +238,43 @@ export const getSalarios = (params?: { limit?: number; offset?: number }) =>
api.get<Transaction[]>('/salarios/', { params });
export const getSalariosSummary = () =>
api.get<SalariosSummary>('/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<PensionUploadResult>('/pensions/upload', form);
};
export const getPensionSnapshots = () =>
api.get<PensionSnapshot[]>('/pensions/snapshots');
export const getPensionFundSummary = () =>
api.get<PensionSnapshot[]>('/pensions/fund-summary');

View File

@@ -4,6 +4,7 @@ import {
Calculator,
BarChart3,
Landmark,
PiggyBank,
LogOut,
Wallet,
Menu,
@@ -29,6 +30,7 @@ const navItems = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
{ to: '/budget', icon: Calculator, label: 'Presupuesto' },
{ to: '/salarios', icon: Landmark, label: 'Salarios' },
{ to: '/pensions', icon: PiggyBank, label: 'Pensiones' },
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
];

View File

@@ -0,0 +1,868 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import {
PiggyBank,
Upload,
X,
TrendingUp,
Calendar,
Percent,
Banknote,
FileText,
Loader2,
CheckCircle2,
AlertTriangle,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
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 ────────────────────────────────────────────────────────────────────
type FundKey = 'FCL' | 'ROP' | 'MPAT' | 'MEMP' | 'VOL';
interface FundDef {
key: FundKey;
name: string;
fullName: string;
color: string;
startBalance: number;
monthlyContribution: number;
annualRate: number;
isDividend: boolean;
withdrawalRule: string;
defaultTargetAge: number;
}
interface ProjectionState {
contribution: number;
rate: number;
targetAge: number;
}
interface ChartDataPoint extends Record<FundKey, number> {
month: string;
}
interface TooltipEntry {
name: string;
value: number;
color: string;
}
// ─── Constants ────────────────────────────────────────────────────────────────
const CURRENT_AGE = 30;
const FUND_KEYS: FundKey[] = ['FCL', 'ROP', 'MPAT', 'MEMP', 'VOL'];
const FUNDS_DEFAULT: Record<FundKey, FundDef> = {
FCL: {
key: 'FCL',
name: 'FCL',
fullName: 'Fondo de Capitalización Laboral',
color: '#3b82f6',
startBalance: 650_468,
monthlyContribution: 150_000,
annualRate: 7.5,
isDividend: false,
withdrawalRule: 'Retirable cada 5 años o al cambio de empleo',
defaultTargetAge: 35,
},
ROP: {
key: 'ROP',
name: 'ROP',
fullName: 'Régimen Obligatorio de Pensiones',
color: '#10b981',
startBalance: 18_684_765,
monthlyContribution: 120_000,
annualRate: 6.0,
isDividend: false,
withdrawalRule: 'Retirable a los 65 años',
defaultTargetAge: 65,
},
MPAT: {
key: 'MPAT',
name: 'MPAT',
fullName: 'Ministerio Patronal',
color: '#f59e0b',
startBalance: 300_000,
monthlyContribution: 200_000,
annualRate: 3.0,
isDividend: true,
withdrawalRule: 'Dividendos anuales en marzo',
defaultTargetAge: 65,
},
MEMP: {
key: 'MEMP',
name: 'MEMP',
fullName: 'Fondo del Empleado',
color: '#8b5cf6',
startBalance: 300_000,
monthlyContribution: 200_000,
annualRate: 3.0,
isDividend: true,
withdrawalRule: '₡100K deducción dos veces al mes · Dividendos en marzo',
defaultTargetAge: 65,
},
VOL: {
key: 'VOL',
name: 'VOL',
fullName: 'Fondo Voluntario',
color: '#f43f5e',
startBalance: 2_500_381,
monthlyContribution: 400_000,
annualRate: 8.0,
isDividend: false,
withdrawalRule: 'Accesible a los 57 años',
defaultTargetAge: 57,
},
};
const MONTHS = [
'Abr 25', 'May 25', 'Jun 25', 'Jul 25',
'Ago 25', 'Sep 25', 'Oct 25', 'Nov 25',
'Dic 25', 'Ene 26', 'Feb 26', 'Mar 26',
];
// ─── Utilities ────────────────────────────────────────────────────────────────
const formatCRC = (amount: number): string =>
new Intl.NumberFormat('es-CR', {
style: 'currency',
currency: 'CRC',
maximumFractionDigits: 0,
}).format(amount);
function generateChartData(funds: Record<FundKey, FundDef>): 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,
};
history[11] = {
month: MONTHS[11],
FCL: Math.round(bal.FCL),
ROP: Math.round(bal.ROP),
MPAT: Math.round(bal.MPAT),
MEMP: Math.round(bal.MEMP),
VOL: Math.round(bal.VOL),
};
for (let i = 10; i >= 0; i--) {
const undoDividend = i === 10;
bal = {
FCL: Math.max(0, (bal.FCL - 150_000) / (1 + 0.075 / 12)),
ROP: Math.max(0, (bal.ROP - 120_000) / (1 + 0.060 / 12)),
MPAT: Math.max(0, undoDividend ? bal.MPAT / 1.03 - 200_000 : bal.MPAT - 200_000),
MEMP: Math.max(0, undoDividend ? bal.MEMP / 1.03 - 200_000 : bal.MEMP - 200_000),
VOL: Math.max(0, (bal.VOL - 400_000) / (1 + 0.08 / 12)),
};
history[i] = {
month: MONTHS[i],
FCL: Math.round(bal.FCL),
ROP: Math.round(bal.ROP),
MPAT: Math.round(bal.MPAT),
MEMP: Math.round(bal.MEMP),
VOL: Math.round(bal.VOL),
};
}
return history;
}
function calcProjection(
currentBalance: number,
monthlyContribution: number,
annualRate: number,
targetAge: number,
isDividend: boolean,
): number {
const years = Math.max(0, targetAge - CURRENT_AGE);
if (years === 0) return currentBalance;
if (isDividend) {
let balance = currentBalance;
const rate = annualRate / 100;
for (let y = 0; y < years; y++) {
balance = balance + monthlyContribution * 12 + balance * rate;
}
return balance;
}
const r = annualRate / 100 / 12;
const n = years * 12;
if (r === 0) return currentBalance + monthlyContribution * n;
return (
currentBalance * Math.pow(1 + r, n) +
monthlyContribution * ((Math.pow(1 + r, n) - 1) / r)
);
}
/** Merge API snapshots into the default fund definitions. */
function applySnapshots(
snapshots: PensionSnapshot[],
): Record<FundKey, FundDef> {
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({
active,
payload,
label,
}: {
active?: boolean;
payload?: TooltipEntry[];
label?: string;
}) {
if (!active || !payload?.length) return null;
return (
<div className="bg-popover border border-border rounded-lg p-3 shadow-lg text-sm min-w-[220px]">
<p className="font-semibold mb-2 text-foreground">{label}</p>
{payload.map((entry) => (
<div key={entry.name} className="flex items-center justify-between gap-4 py-0.5">
<span className="flex items-center gap-1.5">
<span
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{ background: entry.color }}
/>
<span className="text-muted-foreground">{entry.name}</span>
</span>
<span className="font-mono font-medium text-foreground">{formatCRC(entry.value)}</span>
</div>
))}
</div>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export default function Pensions() {
const [fundSummary, setFundSummary] = useState<PensionSnapshot[]>([]);
const [visibleFunds, setVisibleFunds] = useState<Set<FundKey>>(new Set(FUND_KEYS));
const [projections, setProjections] = useState<Record<FundKey, ProjectionState>>({
FCL: { contribution: 150_000, rate: 7.5, targetAge: 35 },
ROP: { contribution: 120_000, rate: 6.0, targetAge: 65 },
MPAT: { contribution: 200_000, rate: 3.0, targetAge: 65 },
MEMP: { contribution: 200_000, rate: 3.0, targetAge: 65 },
VOL: { contribution: 400_000, rate: 8.0, targetAge: 57 },
});
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [uploadResult, setUploadResult] = useState<PensionUploadResult | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
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<Record<FundKey, PensionSnapshot>> = {};
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<Record<FundKey, number>>((acc, key) => {
const snap = snapshotByFund[key];
if (snap) {
// Use real rendimientos from the API
acc[key] = Math.round(snap.rendimientos);
} else {
// 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<FundKey, number>);
}, [FUNDS, chartData, snapshotByFund]);
const toggleFund = (key: FundKey) => {
setVisibleFunds((prev) => {
const next = new Set(prev);
if (next.has(key)) {
if (next.size > 1) next.delete(key);
} else {
next.add(key);
}
return next;
});
};
const updateProjection = (key: FundKey, field: keyof ProjectionState, value: string) => {
const num = parseFloat(value);
setProjections((prev) => ({
...prev,
[key]: { ...prev[key], [field]: isNaN(num) ? 0 : num },
}));
};
const handleFiles = useCallback((files: FileList | null) => {
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<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);
handleFiles(e.dataTransfer.files);
};
const removeFile = (index: number) => {
setUploadedFiles((prev) => prev.filter((_, i) => i !== index));
};
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<div className="space-y-8">
{/* ── Page Header ─────────────────────────────────────────────────── */}
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
<PiggyBank className="w-5 h-5 text-primary" />
</div>
<div>
<h1 className="text-2xl font-bold font-heading">Pensiones</h1>
<p className="text-sm text-muted-foreground">
Seguimiento de aportes, rendimientos y proyecciones
</p>
</div>
</div>
{/* ── Section 1: Fund Overview Cards ──────────────────────────────── */}
<section className="space-y-3">
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
<Banknote className="w-4 h-4" />
Fondos
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
{FUND_KEYS.map((key) => {
const fund = FUNDS[key];
const snap = snapshotByFund[key];
return (
<Card
key={key}
className="border-l-4 overflow-hidden"
style={{ borderLeftColor: fund.color }}
>
<CardContent className="p-4 space-y-3">
<div>
<div className="flex items-center justify-between gap-1">
<span className="font-bold text-base" style={{ color: fund.color }}>
{fund.name}
</span>
<Badge variant={fund.isDividend ? 'secondary' : 'outline'} className="text-xs">
{fund.isDividend ? 'Dividendos' : 'Interés'}
</Badge>
</div>
<p className="text-xs text-muted-foreground leading-tight mt-0.5">
{fund.fullName}
</p>
</div>
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wider font-medium">
Balance actual
</p>
<p className="text-xl font-bold font-mono mt-0.5 leading-tight">
{formatCRC(fund.startBalance)}
</p>
{snap && (
<p className="text-xs text-muted-foreground mt-0.5">
al {new Date(snap.period_end).toLocaleDateString('es-CR')}
</p>
)}
</div>
<Separator />
{snap ? (
<div className="space-y-1.5 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">Aportes</span>
<span className="font-mono font-medium">
{formatCRC(snap.aportes)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Rendimientos</span>
<span className="font-mono font-medium text-emerald-500">
{formatCRC(snap.rendimientos)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Comisión</span>
<span className="font-mono font-medium text-destructive">
{formatCRC(snap.comision)}
</span>
</div>
</div>
) : (
<div className="space-y-1.5 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">Aporte mensual</span>
<span className="font-mono font-medium">
{formatCRC(fund.monthlyContribution)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Tasa anual</span>
<span className="font-mono font-medium">{fund.annualRate}%</span>
</div>
</div>
)}
<div className="flex items-start gap-1.5 text-xs text-muted-foreground bg-muted/50 rounded-md p-2">
<Calendar className="w-3 h-3 flex-shrink-0 mt-0.5" />
<span>{fund.withdrawalRule}</span>
</div>
</CardContent>
</Card>
);
})}
</div>
</section>
{/* ── Section 2: Growth Chart ──────────────────────────────────────── */}
<section className="space-y-3">
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
<TrendingUp className="w-4 h-4" />
Evolución del Balance (Abr 2025 Mar 2026)
</h2>
<Card>
<CardContent className="p-4 space-y-4">
<div className="flex flex-wrap gap-2">
{FUND_KEYS.map((key) => {
const fund = FUNDS[key];
const active = visibleFunds.has(key);
return (
<button
key={key}
onClick={() => toggleFund(key)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all border cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
style={{
borderColor: fund.color,
background: active ? fund.color + '22' : 'transparent',
color: active ? fund.color : 'var(--muted-foreground)',
}}
>
<span
className="w-2 h-2 rounded-full"
style={{ background: active ? fund.color : 'var(--muted-foreground)' }}
/>
{fund.name}
</button>
);
})}
</div>
<ResponsiveContainer width="100%" height={320}>
<LineChart data={chartData} margin={{ top: 4, right: 8, left: 8, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="month"
tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }}
axisLine={{ stroke: 'var(--border)' }}
tickLine={false}
/>
<YAxis
tickFormatter={(v: number) => `${(v / 1_000_000).toFixed(1)}M`}
tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }}
axisLine={false}
tickLine={false}
width={52}
/>
<Tooltip content={<ChartTooltipContent />} />
<Legend
formatter={(value) => (
<span style={{ fontSize: 12, color: 'var(--foreground)' }}>{value}</span>
)}
/>
{FUND_KEYS.map((key) =>
visibleFunds.has(key) ? (
<Line
key={key}
type="monotone"
dataKey={key}
name={key}
stroke={FUNDS[key].color}
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
/>
) : null,
)}
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
</section>
{/* ── Section 3: ROI Summary ───────────────────────────────────────── */}
<section className="space-y-3">
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
<Percent className="w-4 h-4" />
Rendimiento Último Periodo
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
{FUND_KEYS.map((key) => {
const fund = FUNDS[key];
const snap = snapshotByFund[key];
const earned = roiEarned[key];
return (
<Card key={key}>
<CardContent className="p-4">
<div className="flex items-center gap-1.5 mb-2">
<span
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{ background: fund.color }}
/>
<span className="font-bold text-sm">{fund.name}</span>
</div>
{snap ? (
<p className="text-xs text-muted-foreground mb-1">
{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' })}
</p>
) : (
<p className="text-xs text-muted-foreground mb-1">
{fund.isDividend ? `Dividendos ${fund.annualRate}%` : `${fund.annualRate}% anual`}
</p>
)}
<p className="text-lg font-bold font-mono" style={{ color: fund.color }}>
+{formatCRC(earned)}
</p>
<p className="text-xs text-muted-foreground">en rendimientos</p>
</CardContent>
</Card>
);
})}
</div>
</section>
{/* ── Section 4: Projections ───────────────────────────────────────── */}
<section className="space-y-3">
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
<TrendingUp className="w-4 h-4" />
Proyecciones
</h2>
<p className="text-sm text-muted-foreground">
Basado en edad actual de {CURRENT_AGE} años. Edita los campos para simular escenarios.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
{FUND_KEYS.map((key) => {
const fund = FUNDS[key];
const proj = projections[key];
const projected = calcProjection(
fund.startBalance,
proj.contribution,
proj.rate,
proj.targetAge,
fund.isDividend,
);
const years = Math.max(0, proj.targetAge - CURRENT_AGE);
return (
<Card key={key} className="border-l-4" style={{ borderLeftColor: fund.color }}>
<CardHeader className="pb-2 pt-4 px-4">
<CardTitle className="text-sm font-bold" style={{ color: fund.color }}>
{fund.name}
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 space-y-3">
<div className="space-y-2">
<div>
<Label className="text-xs text-muted-foreground">Aporte mensual (CRC)</Label>
<Input
type="number"
value={proj.contribution}
onChange={(e) => updateProjection(key, 'contribution', e.target.value)}
className="h-8 text-sm font-mono mt-1"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">
{fund.isDividend ? 'Tasa dividendo (%)' : 'Tasa anual (%)'}
</Label>
<Input
type="number"
step="0.1"
value={proj.rate}
onChange={(e) => updateProjection(key, 'rate', e.target.value)}
className="h-8 text-sm font-mono mt-1"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">Edad objetivo</Label>
<Input
type="number"
value={proj.targetAge}
onChange={(e) => updateProjection(key, 'targetAge', e.target.value)}
className="h-8 text-sm font-mono mt-1"
/>
</div>
</div>
<Separator />
<div className="text-center space-y-0.5">
<p className="text-xs text-muted-foreground">
Valor en {years} {years === 1 ? 'año' : 'años'}
</p>
<p
className="text-lg font-bold font-mono leading-tight"
style={{ color: fund.color }}
>
{formatCRC(Math.round(projected))}
</p>
</div>
</CardContent>
</Card>
);
})}
</div>
</section>
{/* ── Section 5: PDF Upload ────────────────────────────────────────── */}
<section className="space-y-3">
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
<FileText className="w-4 h-4" />
Estados de Cuenta
</h2>
<Card>
<CardContent className="p-6 space-y-4">
{/* Drop zone */}
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && fileInputRef.current?.click()}
aria-label="Seleccionar archivos PDF"
className={[
'border-2 border-dashed rounded-lg p-8',
'flex flex-col items-center justify-center gap-3',
'cursor-pointer transition-colors select-none',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
isDragging
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50 hover:bg-muted/30',
].join(' ')}
>
<Upload
className={`w-8 h-8 ${isDragging ? 'text-primary' : 'text-muted-foreground'}`}
/>
<div className="text-center">
<p className="text-sm font-medium">
{isDragging
? 'Suelta los archivos aquí'
: 'Arrastra PDFs aquí o toca para seleccionar'}
</p>
<p className="text-xs text-muted-foreground mt-1">
Solo archivos PDF · Múltiples archivos soportados
</p>
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="application/pdf"
multiple
className="hidden"
onChange={(e) => handleFiles(e.target.files)}
/>
{/* File list */}
{uploadedFiles.length > 0 && (
<div className="space-y-2">
<p className="text-sm text-muted-foreground font-medium">
{uploadedFiles.length}{' '}
{uploadedFiles.length === 1 ? 'archivo en cola' : 'archivos en cola'}
</p>
<div className="space-y-1.5">
{uploadedFiles.map((file, i) => (
<div
key={i}
className="flex items-center justify-between gap-3 p-2.5 rounded-lg bg-muted/50 border border-border"
>
<div className="flex items-center gap-2.5 min-w-0">
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{file.name}</p>
<p className="text-xs text-muted-foreground">{formatFileSize(file.size)}</p>
</div>
</div>
<button
onClick={() => removeFile(i)}
className="flex-shrink-0 p-1 rounded hover:bg-destructive/10 hover:text-destructive transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`Eliminar ${file.name}`}
>
<X className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
</div>
)}
{/* Submit */}
<Button
onClick={handleUpload}
disabled={uploadedFiles.length === 0 || isUploading}
className="w-full"
>
{isUploading ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Upload className="w-4 h-4 mr-2" />
)}
{isUploading ? 'Procesando...' : 'Subir PDFs'}
</Button>
{/* Upload result */}
{uploadResult && (
<div className={[
'rounded-lg border p-4 space-y-2',
uploadResult.errors.length > 0 && uploadResult.imported === 0
? 'border-destructive/50 bg-destructive/5'
: 'border-emerald-500/50 bg-emerald-500/5',
].join(' ')}>
<div className="flex items-center gap-2">
{uploadResult.imported > 0 ? (
<CheckCircle2 className="w-4 h-4 text-emerald-500" />
) : (
<AlertTriangle className="w-4 h-4 text-amber-500" />
)}
<span className="text-sm font-medium">
{uploadResult.imported > 0
? `${uploadResult.imported} ${uploadResult.imported === 1 ? 'extracto importado' : 'extractos importados'}`
: 'Ningún extracto nuevo importado'}
</span>
</div>
{uploadResult.duplicates > 0 && (
<p className="text-xs text-muted-foreground">
{uploadResult.duplicates} {uploadResult.duplicates === 1 ? 'duplicado omitido' : 'duplicados omitidos'}
</p>
)}
{uploadResult.errors.map((err, i) => (
<p key={i} className="text-xs text-destructive">{err}</p>
))}
{uploadResult.snapshots.length > 0 && (
<div className="space-y-1 pt-1">
{uploadResult.snapshots.map((snap) => (
<div key={snap.id} className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">
{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' })}
</span>
<span className="font-mono font-medium">{formatCRC(snap.saldo_final)}</span>
</div>
))}
</div>
)}
</div>
)}
</CardContent>
</Card>
</section>
</div>
);
}