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>
This commit is contained in:
Carlos Escalante
2026-03-28 22:24:42 -06:00
parent 1b90f0c70a
commit eccfd53e0b
8 changed files with 631 additions and 56 deletions

View File

@@ -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<FundKey, FundDef> = {
const FUNDS_DEFAULT: Record<FundKey, FundDef> = {
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<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,
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<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({
@@ -257,6 +270,7 @@ function ChartTooltipContent({
// ─── 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 },
@@ -267,28 +281,57 @@ export default function Pensions() {
});
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 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(() => {
return FUND_KEYS.reduce<Record<FundKey, number>>((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<FundKey, number>);
}, [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<HTMLDivElement>) => {
e.preventDefault();
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">
{FUND_KEYS.map((key) => {
const fund = FUNDS[key];
const snap = snapshotByFund[key];
return (
<Card
key={key}
@@ -394,22 +461,50 @@ export default function Pensions() {
<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 />
<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>
{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="flex justify-between">
<span className="text-muted-foreground">Tasa anual</span>
<span className="font-mono font-medium">{fund.annualRate}%</span>
) : (
<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>
)}
<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" />
@@ -501,11 +596,12 @@ export default function Pensions() {
<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 Últimos 12 meses
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}>
@@ -517,9 +613,17 @@ export default function Pensions() {
/>
<span className="font-bold text-sm">{fund.name}</span>
</div>
<p className="text-xs text-muted-foreground mb-1">
{fund.isDividend ? `Dividendos ${fund.annualRate}%` : `${fund.annualRate}% anual`}
</p>
{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>
@@ -698,16 +802,64 @@ export default function Pensions() {
</div>
)}
{/* Submit with "Próximamente" tooltip */}
<div className="relative group w-full">
<Button disabled className="w-full cursor-not-allowed" aria-label="Subir PDFs">
{/* 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" />
Subir PDFs
</Button>
<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">
Próximamente
</span>
</div>
)}
{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>