mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 08:48:48 +02:00
Add pension PDF upload, parsing, and fund summary API
All checks were successful
Deploy to VPS / deploy (push) Successful in 48s
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>
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends poppler-utils && rm -rf /var/lib/apt/lists/*
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends poppler-utils && rm -rf /var/lib/apt/lists/*
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
121
backend/app/api/v1/endpoints/pensions.py
Normal file
121
backend/app/api/v1/endpoints/pensions.py
Normal 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
|
||||||
@@ -9,6 +9,7 @@ from app.api.v1.endpoints import (
|
|||||||
exchange_rate,
|
exchange_rate,
|
||||||
import_transactions,
|
import_transactions,
|
||||||
notifications,
|
notifications,
|
||||||
|
pensions,
|
||||||
salarios,
|
salarios,
|
||||||
settings,
|
settings,
|
||||||
tokens,
|
tokens,
|
||||||
@@ -28,3 +29,4 @@ api_router.include_router(settings.router)
|
|||||||
api_router.include_router(budget.router)
|
api_router.include_router(budget.router)
|
||||||
api_router.include_router(notifications.router)
|
api_router.include_router(notifications.router)
|
||||||
api_router.include_router(salarios.router)
|
api_router.include_router(salarios.router)
|
||||||
|
api_router.include_router(pensions.router)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import enum
|
import enum
|
||||||
from datetime import datetime
|
from datetime import date, datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import JSON, Column
|
from sqlalchemy import JSON, Column, UniqueConstraint
|
||||||
from sqlmodel import Field, Relationship, SQLModel
|
from sqlmodel import Field, Relationship, SQLModel
|
||||||
|
|
||||||
|
|
||||||
@@ -300,3 +300,36 @@ class PushSubscription(SQLModel, table=True):
|
|||||||
class PushSubscriptionCreate(SQLModel):
|
class PushSubscriptionCreate(SQLModel):
|
||||||
endpoint: str
|
endpoint: str
|
||||||
keys: dict # {"p256dh": "...", "auth": "..."}
|
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
|
||||||
|
|||||||
225
backend/app/services/pension_pdf.py
Normal file
225
backend/app/services/pension_pdf.py
Normal 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"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)
|
||||||
@@ -238,3 +238,43 @@ export const getSalarios = (params?: { limit?: number; offset?: number }) =>
|
|||||||
api.get<Transaction[]>('/salarios/', { params });
|
api.get<Transaction[]>('/salarios/', { params });
|
||||||
export const getSalariosSummary = () =>
|
export const getSalariosSummary = () =>
|
||||||
api.get<SalariosSummary>('/salarios/summary');
|
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');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useMemo, useCallback, useRef } from 'react';
|
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
LineChart,
|
LineChart,
|
||||||
Line,
|
Line,
|
||||||
@@ -18,6 +18,9 @@ import {
|
|||||||
Percent,
|
Percent,
|
||||||
Banknote,
|
Banknote,
|
||||||
FileText,
|
FileText,
|
||||||
|
Loader2,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertTriangle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -25,6 +28,12 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import {
|
||||||
|
uploadPensionPDFs,
|
||||||
|
getPensionFundSummary,
|
||||||
|
type PensionSnapshot,
|
||||||
|
type PensionUploadResult,
|
||||||
|
} from '@/api';
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -65,7 +74,7 @@ const CURRENT_AGE = 30;
|
|||||||
|
|
||||||
const FUND_KEYS: FundKey[] = ['FCL', 'ROP', 'MPAT', 'MEMP', 'VOL'];
|
const FUND_KEYS: FundKey[] = ['FCL', 'ROP', 'MPAT', 'MEMP', 'VOL'];
|
||||||
|
|
||||||
const FUNDS: Record<FundKey, FundDef> = {
|
const FUNDS_DEFAULT: Record<FundKey, FundDef> = {
|
||||||
FCL: {
|
FCL: {
|
||||||
key: 'FCL',
|
key: 'FCL',
|
||||||
name: 'FCL',
|
name: 'FCL',
|
||||||
@@ -143,24 +152,15 @@ const formatCRC = (amount: number): string =>
|
|||||||
maximumFractionDigits: 0,
|
maximumFractionDigits: 0,
|
||||||
}).format(amount);
|
}).format(amount);
|
||||||
|
|
||||||
function generateChartData(): ChartDataPoint[] {
|
function generateChartData(funds: Record<FundKey, FundDef>): 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
|
|
||||||
|
|
||||||
const history: ChartDataPoint[] = new Array(12);
|
const history: ChartDataPoint[] = new Array(12);
|
||||||
|
|
||||||
let bal = {
|
let bal = {
|
||||||
FCL: FUNDS.FCL.startBalance,
|
FCL: funds.FCL.startBalance,
|
||||||
ROP: FUNDS.ROP.startBalance,
|
ROP: funds.ROP.startBalance,
|
||||||
MPAT: FUNDS.MPAT.startBalance,
|
MPAT: funds.MPAT.startBalance,
|
||||||
MEMP: FUNDS.MEMP.startBalance,
|
MEMP: funds.MEMP.startBalance,
|
||||||
VOL: FUNDS.VOL.startBalance,
|
VOL: funds.VOL.startBalance,
|
||||||
};
|
};
|
||||||
|
|
||||||
history[11] = {
|
history[11] = {
|
||||||
@@ -173,7 +173,6 @@ function generateChartData(): ChartDataPoint[] {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (let i = 10; i >= 0; i--) {
|
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;
|
const undoDividend = i === 10;
|
||||||
bal = {
|
bal = {
|
||||||
FCL: Math.max(0, (bal.FCL - 150_000) / (1 + 0.075 / 12)),
|
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<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 ────────────────────────────────────────────────────────────
|
// ─── Chart Tooltip ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ChartTooltipContent({
|
function ChartTooltipContent({
|
||||||
@@ -257,6 +270,7 @@ function ChartTooltipContent({
|
|||||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function Pensions() {
|
export default function Pensions() {
|
||||||
|
const [fundSummary, setFundSummary] = useState<PensionSnapshot[]>([]);
|
||||||
const [visibleFunds, setVisibleFunds] = useState<Set<FundKey>>(new Set(FUND_KEYS));
|
const [visibleFunds, setVisibleFunds] = useState<Set<FundKey>>(new Set(FUND_KEYS));
|
||||||
const [projections, setProjections] = useState<Record<FundKey, ProjectionState>>({
|
const [projections, setProjections] = useState<Record<FundKey, ProjectionState>>({
|
||||||
FCL: { contribution: 150_000, rate: 7.5, targetAge: 35 },
|
FCL: { contribution: 150_000, rate: 7.5, targetAge: 35 },
|
||||||
@@ -267,28 +281,57 @@ export default function Pensions() {
|
|||||||
});
|
});
|
||||||
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
|
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [uploadResult, setUploadResult] = useState<PensionUploadResult | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(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<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(() => {
|
const roiEarned = useMemo(() => {
|
||||||
return FUND_KEYS.reduce<Record<FundKey, number>>((acc, key) => {
|
return FUND_KEYS.reduce<Record<FundKey, number>>((acc, key) => {
|
||||||
const fund = FUNDS[key];
|
const snap = snapshotByFund[key];
|
||||||
if (fund.isDividend) {
|
if (snap) {
|
||||||
// Dividend earned in March = balance after dividend − balance before dividend
|
// Use real rendimientos from the API
|
||||||
// history[11] = (history[10] + contribution) × 1.03
|
acc[key] = Math.round(snap.rendimientos);
|
||||||
// → dividend = history[11] − history[10] − contribution
|
|
||||||
acc[key] = Math.max(0, Math.round(
|
|
||||||
chartData[11][key] - chartData[10][key] - fund.monthlyContribution,
|
|
||||||
));
|
|
||||||
} else {
|
} else {
|
||||||
// Approximate interest earned = currentBalance × annualRate × (activeMonths / 12)
|
// Fallback: approximate from hardcoded data
|
||||||
const activeMonths = chartData.filter((d) => d[key] > 0).length;
|
const fund = FUNDS[key];
|
||||||
acc[key] = Math.round(fund.startBalance * (fund.annualRate / 100) * (activeMonths / 12));
|
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;
|
return acc;
|
||||||
}, {} as Record<FundKey, number>);
|
}, {} as Record<FundKey, number>);
|
||||||
}, [chartData]);
|
}, [FUNDS, chartData, snapshotByFund]);
|
||||||
|
|
||||||
const toggleFund = (key: FundKey) => {
|
const toggleFund = (key: FundKey) => {
|
||||||
setVisibleFunds((prev) => {
|
setVisibleFunds((prev) => {
|
||||||
@@ -314,8 +357,31 @@ export default function Pensions() {
|
|||||||
if (!files) return;
|
if (!files) return;
|
||||||
const pdfs = Array.from(files).filter((f) => f.type === 'application/pdf');
|
const pdfs = Array.from(files).filter((f) => f.type === 'application/pdf');
|
||||||
setUploadedFiles((prev) => [...prev, ...pdfs]);
|
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>) => {
|
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
@@ -366,6 +432,7 @@ export default function Pensions() {
|
|||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
|
||||||
{FUND_KEYS.map((key) => {
|
{FUND_KEYS.map((key) => {
|
||||||
const fund = FUNDS[key];
|
const fund = FUNDS[key];
|
||||||
|
const snap = snapshotByFund[key];
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={key}
|
key={key}
|
||||||
@@ -394,22 +461,50 @@ export default function Pensions() {
|
|||||||
<p className="text-xl font-bold font-mono mt-0.5 leading-tight">
|
<p className="text-xl font-bold font-mono mt-0.5 leading-tight">
|
||||||
{formatCRC(fund.startBalance)}
|
{formatCRC(fund.startBalance)}
|
||||||
</p>
|
</p>
|
||||||
|
{snap && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
al {new Date(snap.period_end).toLocaleDateString('es-CR')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className="space-y-1.5 text-xs">
|
{snap ? (
|
||||||
<div className="flex justify-between">
|
<div className="space-y-1.5 text-xs">
|
||||||
<span className="text-muted-foreground">Aporte mensual</span>
|
<div className="flex justify-between">
|
||||||
<span className="font-mono font-medium">
|
<span className="text-muted-foreground">Aportes</span>
|
||||||
{formatCRC(fund.monthlyContribution)}
|
<span className="font-mono font-medium">
|
||||||
</span>
|
{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>
|
||||||
<div className="flex justify-between">
|
) : (
|
||||||
<span className="text-muted-foreground">Tasa anual</span>
|
<div className="space-y-1.5 text-xs">
|
||||||
<span className="font-mono font-medium">{fund.annualRate}%</span>
|
<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>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<div className="flex items-start gap-1.5 text-xs text-muted-foreground bg-muted/50 rounded-md p-2">
|
<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" />
|
<Calendar className="w-3 h-3 flex-shrink-0 mt-0.5" />
|
||||||
@@ -501,11 +596,12 @@ export default function Pensions() {
|
|||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
|
<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" />
|
<Percent className="w-4 h-4" />
|
||||||
Rendimiento — Últimos 12 meses
|
Rendimiento — Último Periodo
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||||
{FUND_KEYS.map((key) => {
|
{FUND_KEYS.map((key) => {
|
||||||
const fund = FUNDS[key];
|
const fund = FUNDS[key];
|
||||||
|
const snap = snapshotByFund[key];
|
||||||
const earned = roiEarned[key];
|
const earned = roiEarned[key];
|
||||||
return (
|
return (
|
||||||
<Card key={key}>
|
<Card key={key}>
|
||||||
@@ -517,9 +613,17 @@ export default function Pensions() {
|
|||||||
/>
|
/>
|
||||||
<span className="font-bold text-sm">{fund.name}</span>
|
<span className="font-bold text-sm">{fund.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mb-1">
|
{snap ? (
|
||||||
{fund.isDividend ? `Dividendos ${fund.annualRate}%` : `${fund.annualRate}% anual`}
|
<p className="text-xs text-muted-foreground mb-1">
|
||||||
</p>
|
{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 }}>
|
<p className="text-lg font-bold font-mono" style={{ color: fund.color }}>
|
||||||
+{formatCRC(earned)}
|
+{formatCRC(earned)}
|
||||||
</p>
|
</p>
|
||||||
@@ -698,16 +802,64 @@ export default function Pensions() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Submit with "Próximamente" tooltip */}
|
{/* Submit */}
|
||||||
<div className="relative group w-full">
|
<Button
|
||||||
<Button disabled className="w-full cursor-not-allowed" aria-label="Subir PDFs">
|
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" />
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
Subir PDFs
|
)}
|
||||||
</Button>
|
{isUploading ? 'Procesando...' : 'Subir PDFs'}
|
||||||
<span className="absolute -top-9 left-1/2 -translate-x-1/2 px-2.5 py-1.5 text-xs rounded-md bg-popover border border-border text-popover-foreground shadow-sm opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-10 font-medium">
|
</Button>
|
||||||
Próximamente
|
|
||||||
</span>
|
{/* Upload result */}
|
||||||
</div>
|
{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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user