feat: ecosistema FRASA — apps Cotizador, Alta Clientes, Cobranza y Landing

- Cotizador (/quote): 4 propuestas con formulas validadas del Excel, comparador,
  combobox de unidades, vigencia, PDF ANEXO A
- Alta Clientes (/client): wizard 8 pasos, validacion CURP RENAPO, OCR de INE,
  checklist de expediente, firma digital, 5 PDFs legales
- Cobranza (/pay): planes de enganche, mora, vista cobranza del dia, recibos PDF,
  bitacora, dashboard con filtros
- Landing (/): entrada al ecosistema
- Design system StudioBlank (monocromo, acento #1B4332) en las 4 apps
- 123 tests vitest
This commit is contained in:
2026-08-21 22:27:36 +00:00
commit ecd4edac02
147 changed files with 27345 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
node_modules
dist
.git
.gitignore
*.log
coverage
.vscode
Dockerfile
.dockerignore

5
apps/cobranza/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules
dist
*.log
coverage
*.tsbuildinfo

14
apps/cobranza/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
# Etapa 1: build
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Etapa 2: servidor estático
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

12
apps/cobranza/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FRASA Desarrollos — Cobranza</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

18
apps/cobranza/nginx.conf Normal file
View File

@@ -0,0 +1,18 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA fallback: cualquier ruta no existente sirve index.html
location / {
try_files $uri $uri/ /index.html;
}
# Assets con hash: cache agresivo
location /pay/assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

4525
apps/cobranza/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
{
"name": "cobranza",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"test": "vitest run",
"preview": "vite preview"
},
"dependencies": {
"@fontsource/inter": "^5.3.0",
"@hookform/resolvers": "^3.9.1",
"jspdf": "^2.5.2",
"jspdf-autotable": "^3.8.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"react-router-dom": "^7.1.1",
"recharts": "^2.15.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0",
"typescript": "~5.6.3",
"vite": "^6.0.7",
"vitest": "^2.1.8"
}
}

View File

@@ -0,0 +1,18 @@
import type { StatusPago } from '../types'
const CLASES: Record<StatusPago, string> = {
PENDIENTE: 'border-neutral-300 text-neutral-500',
PAGADO: 'border-green-300 text-green-700',
VENCIDO: 'border-red-300 text-red-700',
PARCIAL: 'border-amber-300 text-amber-700',
}
export default function BadgeStatus({ status }: { status: StatusPago }) {
return (
<span
className={`inline-block border bg-white px-2 py-0.5 text-xs font-bold uppercase tracking-wider ${CLASES[status]}`}
>
{status}
</span>
)
}

View File

@@ -0,0 +1,11 @@
import type { ReactNode } from 'react'
export default function KpiCard({ titulo, valor, detalle }: { titulo: string; valor: ReactNode; detalle?: string }) {
return (
<div className="border border-neutral-200 bg-white p-6">
<p className="text-xs font-bold uppercase tracking-widest text-neutral-500">{titulo}</p>
<p className="mt-2 text-3xl font-light text-neutral-900">{valor}</p>
{detalle && <p className="mt-2 text-xs text-neutral-500">{detalle}</p>}
</div>
)
}

View File

@@ -0,0 +1,38 @@
import type { ReactNode } from 'react'
import { Link } from 'react-router-dom'
export default function Layout({ children }: { children: ReactNode }) {
return (
<div className="min-h-screen bg-white text-neutral-900">
<header className="border-b border-neutral-200">
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-4">
<Link to="/" className="text-sm tracking-widest">
<span className="font-bold">FRASA</span>{' '}
<span className="font-light text-neutral-500">COBRANZA</span>
</Link>
<nav className="flex items-center gap-6 text-sm">
<Link
to="/"
className="text-neutral-500 transition-colors duration-150 hover:text-neutral-900"
>
Cobranza
</Link>
<Link
to="/hoy"
className="border border-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-accent transition-colors duration-150 hover:bg-accent hover:text-white"
>
Cobranza del día
</Link>
<Link
to="/nuevo"
className="bg-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:opacity-90"
>
Nuevo plan
</Link>
</nav>
</div>
</header>
<main className="mx-auto max-w-6xl px-4 py-16">{children}</main>
</div>
)
}

View File

@@ -0,0 +1,141 @@
import { useEffect, useRef } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import type { Pago } from '../types'
import { fechaISOHoy, formatoMoneda } from '../lib/formatters'
import { round2 } from '../lib/cobranza'
function esquemaAbono(restante: number) {
return z.object({
monto: z
.number({ invalid_type_error: 'Captura un monto válido' })
.positive('El monto debe ser mayor a 0')
.max(restante, `No puede exceder el restante (${formatoMoneda(restante)})`),
fechaPago: z.string().min(1, 'La fecha es requerida'),
referencia: z.string().trim().min(3, 'Referencia requerida (mín. 3 caracteres)'),
})
}
type AbonoForm = z.infer<ReturnType<typeof esquemaAbono>>
interface Props {
pago: Pago
onCancelar: () => void
onGuardar: (datos: AbonoForm) => void
}
const claseLabel = 'block text-xs font-bold uppercase tracking-widest text-neutral-500'
/** Modal para registrar un abono (total o parcial) a un pago del plan */
export default function ModalAbono({ pago, onCancelar, onGuardar }: Props) {
const restante = round2(pago.monto - pago.montoPagado)
const {
register,
handleSubmit,
formState: { errors },
} = useForm<AbonoForm>({
resolver: zodResolver(esquemaAbono(restante)),
defaultValues: { monto: restante, fechaPago: fechaISOHoy(), referencia: '' },
})
// Foco al primer input al abrir
const refMonto = useRef<HTMLInputElement | null>(null)
useEffect(() => {
refMonto.current?.focus()
}, [])
// Escape cierra el modal
useEffect(() => {
const alTeclar = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCancelar()
}
window.addEventListener('keydown', alTeclar)
return () => window.removeEventListener('keydown', alTeclar)
}, [onCancelar])
const regMonto = register('monto', { valueAsNumber: true })
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div
role="dialog"
aria-modal="true"
aria-labelledby="titulo-abono"
className="w-full max-w-md border border-neutral-900 bg-white p-8"
>
<h2 id="titulo-abono" className="text-xl font-light text-neutral-900">
Registrar abono — Pago #{pago.numPago}
</h2>
<p className="mt-2 text-sm text-neutral-500">
Restante: <span className="font-bold text-neutral-900">{formatoMoneda(restante)}</span>
</p>
<form
className="mt-8 space-y-6"
onSubmit={(e) => {
void handleSubmit(onGuardar)(e)
}}
>
<div>
<label htmlFor="abono-monto" className={claseLabel}>
Monto (MXN)
</label>
<input
id="abono-monto"
type="number"
step="0.01"
className={`mt-1 w-full border bg-white px-3 py-2.5 text-sm focus:border-neutral-900 focus:outline-none ${errors.monto ? 'border-red-400' : 'border-neutral-300'}`}
{...regMonto}
ref={(el) => {
regMonto.ref(el)
refMonto.current = el
}}
/>
{errors.monto && <p className="mt-1 text-xs text-red-700">{errors.monto.message}</p>}
</div>
<div>
<label htmlFor="abono-fecha" className={claseLabel}>
Fecha de pago
</label>
<input
id="abono-fecha"
type="date"
className={`mt-1 w-full border bg-white px-3 py-2.5 text-sm focus:border-neutral-900 focus:outline-none ${errors.fechaPago ? 'border-red-400' : 'border-neutral-300'}`}
{...register('fechaPago')}
/>
{errors.fechaPago && <p className="mt-1 text-xs text-red-700">{errors.fechaPago.message}</p>}
</div>
<div>
<label htmlFor="abono-referencia" className={claseLabel}>
Referencia bancaria / folio
</label>
<input
id="abono-referencia"
type="text"
className={`mt-1 w-full border bg-white px-3 py-2.5 text-sm focus:border-neutral-900 focus:outline-none ${errors.referencia ? 'border-red-400' : 'border-neutral-300'}`}
placeholder="SPEI-000123"
{...register('referencia')}
/>
{errors.referencia && <p className="mt-1 text-xs text-red-700">{errors.referencia.message}</p>}
</div>
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={onCancelar}
aria-label="Cancelar y cerrar el registro de abono"
className="border border-neutral-900 bg-transparent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-neutral-900 transition-colors duration-150 hover:bg-neutral-900 hover:text-white"
>
Cancelar
</button>
<button
type="submit"
className="bg-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:opacity-90 disabled:opacity-40"
>
Guardar abono
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,45 @@
import type { PlanPago } from '../types'
import { diasParaProximoVencimiento, statusPago, statusPlan } from '../lib/cobranza'
export type Semaforo = 'VERDE' | 'AMBAR' | 'ROJO' | 'GRIS'
const ETIQUETA: Record<Semaforo, string> = {
VERDE: 'Al corriente',
AMBAR: 'Por vencer / parcial',
ROJO: 'Vencido',
GRIS: 'Cerrado',
}
const CLASE_DOT: Record<Semaforo, string> = {
VERDE: 'border-green-300 bg-white text-green-700',
AMBAR: 'border-amber-300 bg-white text-amber-700',
ROJO: 'border-red-300 bg-white text-red-700',
GRIS: 'border-neutral-300 bg-white text-neutral-500',
}
/**
* Semáforo del plan:
* - gris: LIQUIDADO o CANCELADO
* - rojo: tiene pagos VENCIDOS
* - ámbar: tiene un pago PARCIAL o el próximo vencimiento es en 7 días o menos
* - verde: al corriente
*/
export function semaforoPlan(plan: PlanPago, hoy: Date = new Date()): Semaforo {
if (statusPlan(plan) !== 'ACTIVO') return 'GRIS'
const estados = plan.pagos.map((p) => statusPago(p, hoy))
if (estados.includes('VENCIDO')) return 'ROJO'
if (estados.includes('PARCIAL')) return 'AMBAR'
const dias = diasParaProximoVencimiento(plan, hoy)
if (dias !== null && dias <= 7) return 'AMBAR'
return 'VERDE'
}
export default function SemaforoDot({ valor }: { valor: Semaforo }) {
return (
<span
className={`inline-block border px-2 py-0.5 text-xs font-bold uppercase tracking-wider ${CLASE_DOT[valor]}`}
>
{ETIQUETA[valor]}
</span>
)
}

View File

@@ -0,0 +1,124 @@
import type { PlanPago } from '../types'
import { generarCalendario, registrarAbono, sumarMeses } from '../lib/cobranza'
import { uuid } from '../lib/uuid'
function aISO(fecha: Date): string {
const y = fecha.getFullYear()
const m = String(fecha.getMonth() + 1).padStart(2, '0')
const d = String(fecha.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
interface DatosPlan {
clienteNombre: string
clienteTelefono?: string
unidadCodigo: string
proyecto: string
ventaRef: string
fechaInicio: string
engancheTotal: number
numPagos: number
tasaMoraMensual?: number
}
function crearPlanBase(datos: DatosPlan): PlanPago {
const { mensualidad, pagos } = generarCalendario(datos.fechaInicio, datos.engancheTotal, datos.numPagos)
return {
id: uuid(),
ventaRef: datos.ventaRef,
clienteNombre: datos.clienteNombre,
clienteTelefono: datos.clienteTelefono,
unidadCodigo: datos.unidadCodigo,
proyecto: datos.proyecto,
fechaInicio: datos.fechaInicio,
engancheTotal: datos.engancheTotal,
numPagos: datos.numPagos,
mensualidad,
tasaMoraMensual: datos.tasaMoraMensual,
status: 'ACTIVO',
pagos,
notas: [],
createdAt: new Date().toISOString(),
}
}
/** Abona por número de pago (1..N); `monto` por defecto cubre el pago completo */
function abonar(plan: PlanPago, numPago: number, fechaPago: string, referencia: string, monto?: number): PlanPago {
const pago = plan.pagos[numPago - 1]
return registrarAbono(plan, {
pagoId: pago.id,
fechaPago,
monto: monto ?? pago.monto - pago.montoPagado,
referencia,
})
}
/**
* 4 planes de ejemplo con fechas relativas a `hoy`, para que el demo siempre
* muestre todos los estados: al corriente, con vencidos, con parcial y liquidado.
*/
export function generarSeed(hoy: Date = new Date()): PlanPago[] {
// 1. Al corriente: 6 pagos desde hace 3 meses, los primeros 3 pagados
let alCorriente = crearPlanBase({
clienteNombre: 'María Fernanda López Ríos',
clienteTelefono: '55 1234 8890',
unidadCodigo: 'MB-204',
proyecto: 'RESIDENCIAL MONTEBELLO',
ventaRef: 'VTA-2026-014',
fechaInicio: aISO(sumarMeses(hoy, -3)),
engancheTotal: 180000,
numPagos: 6,
})
for (let i = 1; i <= 3; i++) {
alCorriente = abonar(alCorriente, i, alCorriente.pagos[i - 1].fechaVencimiento, `SPEI-MB204-0${i}`)
}
// 2. Con 2 pagos vencidos: 6 pagos desde hace 5 meses, solo 3 pagados
let conVencidos = crearPlanBase({
clienteNombre: 'Jorge Alberto Camacho Vega',
clienteTelefono: '55 8842 1177',
unidadCodigo: 'U-101',
proyecto: 'SENDA',
ventaRef: 'VTA-2025-089',
fechaInicio: aISO(sumarMeses(hoy, -5)),
engancheTotal: 150000,
numPagos: 6,
tasaMoraMensual: 2,
})
for (let i = 1; i <= 3; i++) {
conVencidos = abonar(conVencidos, i, conVencidos.pagos[i - 1].fechaVencimiento, `SPEI-U101-0${i}`)
}
// 3. Con un pago PARCIAL: 4 pagos desde hace 2 meses; pago 1 completo, pago 2 con abono parcial ya vencido
let conParcial = crearPlanBase({
clienteNombre: 'Ana Sofía Guillén Torres',
clienteTelefono: '55 6021 3345',
unidadCodigo: 'DG-302',
proyecto: 'DIAGONAL',
ventaRef: 'VTA-2026-021',
fechaInicio: aISO(sumarMeses(hoy, -2)),
engancheTotal: 120000,
numPagos: 4,
tasaMoraMensual: 1.5,
})
conParcial = abonar(conParcial, 1, conParcial.pagos[0].fechaVencimiento, 'SPEI-DG302-01')
conParcial = abonar(conParcial, 2, aISO(sumarMeses(hoy, -1)), 'EFECTIVO-DG302-02', 10000)
// 4. Liquidado: 6 pagos desde hace 6 meses, todos pagados
let liquidado = crearPlanBase({
clienteNombre: 'Roberto Mendoza Salas',
clienteTelefono: '55 9987 4456',
unidadCodigo: 'MB-118',
proyecto: 'RESIDENCIAL MONTEBELLO',
ventaRef: 'VTA-2025-041',
fechaInicio: aISO(sumarMeses(hoy, -6)),
engancheTotal: 90000,
numPagos: 6,
})
for (let i = 1; i <= 6; i++) {
liquidado = abonar(liquidado, i, liquidado.pagos[i - 1].fechaVencimiento, `SPEI-MB118-0${i}`)
}
liquidado = { ...liquidado, status: 'LIQUIDADO' }
return [alCorriente, conVencidos, conParcial, liquidado]
}

View File

@@ -0,0 +1,23 @@
@import "tailwindcss";
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--color-accent: #1B4332;
--radius-sm: 0;
--radius-md: 0;
--radius-lg: 0;
--radius-xl: 0;
--radius-2xl: 0;
--radius-3xl: 0;
}
@layer base {
body {
@apply bg-white text-neutral-900 antialiased;
}
*:focus-visible {
outline: 2px solid #1B4332;
outline-offset: 2px;
}
}

View File

@@ -0,0 +1,326 @@
import { describe, expect, it } from 'vitest'
import type { Pago, PlanPago } from '../types'
import {
agregarNotaAPlan,
alCorriente,
avancePlan,
cobranzaDelDia,
diasAtraso,
generarCalendario,
moraDePago,
pagadoTotal,
proximoVencimiento,
registrarAbono,
round2,
saldoPlan,
statusPago,
statusPlan,
sumarMeses,
totalMora,
} from './cobranza'
const pagoBase = (override: Partial<Pago> = {}): Pago => ({
id: 'p1',
numPago: 1,
fechaVencimiento: '2026-02-15',
monto: 10000,
montoPagado: 0,
aplicaciones: [],
...override,
})
describe('generarCalendario', () => {
it('genera 12 pagos cuya suma es exactamente el enganche', () => {
const { mensualidad, pagos } = generarCalendario('2026-01-15', 180000, 12)
expect(pagos).toHaveLength(12)
expect(mensualidad).toBe(15000)
const suma = pagos.reduce((acc, p) => acc + p.monto, 0)
expect(round2(suma)).toBe(180000)
expect(pagos[0].numPago).toBe(1)
expect(pagos[11].numPago).toBe(12)
expect(pagos[0].fechaVencimiento).toBe('2026-01-15')
expect(pagos[11].fechaVencimiento).toBe('2026-12-15')
})
it('el último pago absorbe el residuo del redondeo (100000 / 3)', () => {
const { mensualidad, pagos } = generarCalendario('2026-03-01', 100000, 3)
expect(mensualidad).toBe(33333.33)
expect(pagos.map((p) => p.monto)).toEqual([33333.33, 33333.33, 33333.34])
expect(round2(pagos.reduce((acc, p) => acc + p.monto, 0))).toBe(100000)
})
it('ajusta el día cuando el mes no lo tiene (31 de enero → 28 de febrero)', () => {
const { pagos } = generarCalendario('2025-01-31', 30000, 3)
expect(pagos[0].fechaVencimiento).toBe('2025-01-31')
expect(pagos[1].fechaVencimiento).toBe('2025-02-28') // 2025 no es bisiesto
expect(pagos[2].fechaVencimiento).toBe('2025-03-31')
})
it('respeta año bisiesto (31 de enero 2024 → 29 de febrero)', () => {
expect(sumarMeses(new Date(2024, 0, 31), 1)).toEqual(new Date(2024, 1, 29))
})
it('rechaza parámetros inválidos', () => {
expect(() => generarCalendario('2026-01-01', 0, 6)).toThrow()
expect(() => generarCalendario('2026-01-01', 1000, 0)).toThrow()
})
})
describe('statusPago', () => {
const hoy = new Date('2026-02-16T00:00:00')
it('PAGADO cuando montoPagado cubre el monto', () => {
expect(statusPago(pagoBase({ montoPagado: 10000 }), hoy)).toBe('PAGADO')
})
it('PARCIAL con abono menor al monto, aunque ya venció', () => {
expect(statusPago(pagoBase({ montoPagado: 4000 }), hoy)).toBe('PARCIAL')
})
it('VENCIDO sin abono y hoy posterior al vencimiento', () => {
expect(statusPago(pagoBase(), hoy)).toBe('VENCIDO')
})
it('PENDIENTE sin abono y hoy en o antes del vencimiento', () => {
expect(statusPago(pagoBase(), new Date('2026-02-15T00:00:00'))).toBe('PENDIENTE')
expect(statusPago(pagoBase(), new Date('2026-02-01T00:00:00'))).toBe('PENDIENTE')
})
})
function planDePrueba(): PlanPago {
const { mensualidad, pagos } = generarCalendario('2026-01-10', 30000, 3)
return {
id: 'plan-1',
ventaRef: 'VTA-001',
clienteNombre: 'Cliente Prueba',
unidadCodigo: 'U-1',
proyecto: 'SENDA',
fechaInicio: '2026-01-10',
engancheTotal: 30000,
numPagos: 3,
mensualidad,
status: 'ACTIVO',
pagos,
notas: [],
createdAt: '2026-01-01T00:00:00.000Z',
}
}
describe('registrarAbono', () => {
it('aplica un abono parcial y lo agrega al historial', () => {
const plan = planDePrueba()
const actualizado = registrarAbono(plan, {
pagoId: plan.pagos[0].id,
fechaPago: '2026-01-10',
monto: 4000,
referencia: 'SPEI-123',
})
expect(actualizado.pagos[0].montoPagado).toBe(4000)
expect(actualizado.pagos[0].aplicaciones).toHaveLength(1)
expect(actualizado.pagos[0].aplicaciones[0].referencia).toBe('SPEI-123')
// no muta el plan original
expect(plan.pagos[0].montoPagado).toBe(0)
})
it('permite completar un pago con varios abonos', () => {
let plan = planDePrueba()
plan = registrarAbono(plan, { pagoId: plan.pagos[0].id, fechaPago: '2026-01-10', monto: 4000, referencia: 'A1' })
plan = registrarAbono(plan, { pagoId: plan.pagos[0].id, fechaPago: '2026-01-11', monto: 6000, referencia: 'A2' })
expect(plan.pagos[0].montoPagado).toBe(10000)
expect(plan.pagos[0].aplicaciones).toHaveLength(2)
expect(statusPago(plan.pagos[0], new Date('2026-01-12T00:00:00'))).toBe('PAGADO')
})
it('rechaza abonos que exceden el restante', () => {
const plan = planDePrueba()
expect(() =>
registrarAbono(plan, { pagoId: plan.pagos[0].id, fechaPago: '2026-01-10', monto: 10000.01, referencia: 'X1' }),
).toThrow(/excede el restante/)
})
it('rechaza abonos menores o iguales a 0', () => {
const plan = planDePrueba()
expect(() =>
registrarAbono(plan, { pagoId: plan.pagos[0].id, fechaPago: '2026-01-10', monto: 0, referencia: 'X1' }),
).toThrow(/mayor a 0/)
expect(() =>
registrarAbono(plan, { pagoId: plan.pagos[0].id, fechaPago: '2026-01-10', monto: -500, referencia: 'X1' }),
).toThrow(/mayor a 0/)
})
it('rechaza un pagoId inexistente', () => {
const plan = planDePrueba()
expect(() =>
registrarAbono(plan, { pagoId: 'no-existe', fechaPago: '2026-01-10', monto: 100, referencia: 'X1' }),
).toThrow(/no encontrado/)
})
})
describe('saldo, avance y status del plan', () => {
it('saldoPlan y pagadoTotal reflejan los abonos', () => {
let plan = planDePrueba()
expect(saldoPlan(plan)).toBe(30000)
expect(pagadoTotal(plan)).toBe(0)
plan = registrarAbono(plan, { pagoId: plan.pagos[0].id, fechaPago: '2026-01-10', monto: 10000, referencia: 'A1' })
expect(saldoPlan(plan)).toBe(20000)
expect(pagadoTotal(plan)).toBe(10000)
expect(avancePlan(plan)).toBeCloseTo(1 / 3, 5)
})
it('el plan queda LIQUIDADO al cubrir todo el enganche', () => {
let plan = planDePrueba()
for (const pago of plan.pagos) {
plan = registrarAbono(plan, {
pagoId: pago.id,
fechaPago: '2026-03-10',
monto: pago.monto - pago.montoPagado,
referencia: `LIQ-${pago.numPago}`,
})
}
expect(saldoPlan(plan)).toBe(0)
expect(statusPlan(plan)).toBe('LIQUIDADO')
})
it('CANCELADO prevalece aunque tenga saldo', () => {
const plan = { ...planDePrueba(), status: 'CANCELADO' as const }
expect(statusPlan(plan)).toBe('CANCELADO')
})
it('alCorriente es falso cuando hay pagos vencidos', () => {
const hoy = new Date('2026-02-15T00:00:00') // pago 1 (10-ene) ya venció
expect(alCorriente(planDePrueba(), hoy)).toBe(false)
expect(alCorriente(planDePrueba(), new Date('2026-01-05T00:00:00'))).toBe(true)
})
})
describe('proximoVencimiento y diasAtraso', () => {
it('devuelve el primer pago no cubierto con fecha >= hoy', () => {
const plan = planDePrueba() // vencimientos 10-ene, 10-feb, 10-mar
const prox = proximoVencimiento(plan, new Date('2026-01-20T00:00:00'))
expect(prox?.numPago).toBe(2)
})
it('si no hay futuros, devuelve el vencido más antiguo', () => {
const plan = planDePrueba()
const prox = proximoVencimiento(plan, new Date('2026-04-01T00:00:00'))
expect(prox?.numPago).toBe(1)
})
it('devuelve null cuando todo está cubierto', () => {
let plan = planDePrueba()
for (const pago of plan.pagos) {
plan = registrarAbono(plan, {
pagoId: pago.id,
fechaPago: '2026-01-10',
monto: pago.monto,
referencia: 'X',
})
}
expect(proximoVencimiento(plan, new Date('2026-01-15T00:00:00'))).toBeNull()
})
it('diasAtraso cuenta desde el vencido más antiguo', () => {
const plan = planDePrueba() // pago 1 vence 10-ene
expect(diasAtraso(plan, new Date('2026-01-25T00:00:00'))).toBe(15)
expect(diasAtraso(plan, new Date('2026-01-10T00:00:00'))).toBe(0)
})
it('un pago PARCIAL vencido no cuenta como atraso', () => {
let plan = planDePrueba()
plan = registrarAbono(plan, { pagoId: plan.pagos[0].id, fechaPago: '2026-01-10', monto: 100, referencia: 'A' })
// pago 1 es PARCIAL (vencido pero con abono); pago 2 vence 10-feb
expect(diasAtraso(plan, new Date('2026-02-01T00:00:00'))).toBe(0)
})
})
describe('moraDePago y totalMora', () => {
// pago de 10000 que venció el 15-feb; hoy = 17-mar → 30 días vencidos
const hoy = new Date('2026-03-17T00:00:00')
it('es 0 cuando la tasa es 0', () => {
expect(moraDePago(pagoBase(), 0, hoy)).toBe(0)
})
it('es 0 cuando el pago no está vencido', () => {
expect(moraDePago(pagoBase(), 2, new Date('2026-02-15T00:00:00'))).toBe(0)
expect(moraDePago(pagoBase(), 2, new Date('2026-02-10T00:00:00'))).toBe(0)
})
it('es 0 cuando el pago ya está cubierto', () => {
expect(moraDePago(pagoBase({ montoPagado: 10000 }), 2, hoy)).toBe(0)
})
it('cálculo exacto: restante × (tasa/100) / 30 × días vencidos', () => {
// 10000 × 0.02 / 30 × 30 = 200
expect(moraDePago(pagoBase(), 2, hoy)).toBe(200)
// 10000 × 0.03 / 30 × 10 = 100 (10 días de atraso)
expect(moraDePago(pagoBase(), 3, new Date('2026-02-25T00:00:00'))).toBe(100)
})
it('un pago PARCIAL vencido genera mora sobre su restante', () => {
// restante 6000 × 0.02 / 30 × 30 = 120
expect(moraDePago(pagoBase({ montoPagado: 4000 }), 2, hoy)).toBe(120)
})
it('totalMora suma la mora de todos los pagos y usa tasa 0 por defecto', () => {
const plan = planDePrueba() // vence 10-ene, 10-feb, 10-mar; hoy 20-mar
const hoyPlan = new Date('2026-03-20T00:00:00')
expect(totalMora(plan, hoyPlan)).toBe(0) // sin tasa configurada
const conTasa: PlanPago = { ...plan, tasaMoraMensual: 3 }
// pago 1: 10000 × 0.001 × 69 = 690; pago 2: 10000 × 0.001 × 38 = 380; pago 3: 10000 × 0.001 × 10 = 100
expect(totalMora(conTasa, hoyPlan)).toBe(1170)
})
})
describe('cobranzaDelDia', () => {
it('incluye vencidos y los que vencen en los próximos 7 días, ordenados por urgencia', () => {
// plan: vencimientos 10-ene, 10-feb, 10-mar; hoy 05-mar
const hoy = new Date('2026-03-05T00:00:00')
const plan = planDePrueba()
const items = cobranzaDelDia([plan], hoy)
// pago 1 (54 días atraso), pago 2 (23 días), pago 3 (vence en 5 días)
expect(items.map((i) => i.pago.numPago)).toEqual([1, 2, 3])
expect(items.map((i) => i.atraso)).toEqual([54, 23, 0])
})
it('excluye pagos cubiertos, planes no activos y vencimientos lejanos', () => {
const hoy = new Date('2026-01-05T00:00:00')
let plan = planDePrueba()
// cubre el pago 1 (vence 10-ene, dentro de la ventana)
plan = registrarAbono(plan, { pagoId: plan.pagos[0].id, fechaPago: '2026-01-05', monto: 10000, referencia: 'A' })
const cancelado: PlanPago = { ...planDePrueba(), status: 'CANCELADO' }
const items = cobranzaDelDia([plan, cancelado], hoy)
// pago 2 (10-feb) y pago 3 (10-mar) están a más de 7 días → lista vacía
expect(items).toHaveLength(0)
// con la ventana amplia solo aparecen los del plan activo
const items40 = cobranzaDelDia([plan, cancelado], hoy, 40)
expect(items40.map((i) => i.pago.numPago)).toEqual([2])
})
it('un PARCIAL vencido aparece con sus días de atraso', () => {
const hoy = new Date('2026-02-01T00:00:00')
let plan = planDePrueba()
plan = registrarAbono(plan, { pagoId: plan.pagos[0].id, fechaPago: '2026-01-10', monto: 4000, referencia: 'A' })
const items = cobranzaDelDia([plan], hoy)
expect(items[0].pago.numPago).toBe(1)
expect(items[0].atraso).toBe(22)
})
})
describe('agregarNotaAPlan', () => {
it('agrega la nota con texto limpio sin mutar el plan', () => {
const plan = planDePrueba()
const actualizado = agregarNotaAPlan(plan, ' Cliente promete pago el viernes ')
expect(actualizado.notas).toHaveLength(1)
expect(actualizado.notas[0].texto).toBe('Cliente promete pago el viernes')
expect(actualizado.notas[0].id).toBeTruthy()
expect(actualizado.notas[0].fecha).toBeTruthy()
expect(plan.notas).toHaveLength(0)
})
it('rechaza notas de menos de 3 caracteres', () => {
const plan = planDePrueba()
expect(() => agregarNotaAPlan(plan, ' ok ')).toThrow(/al menos 3/)
expect(() => agregarNotaAPlan(plan, '')).toThrow(/al menos 3/)
})
})

View File

@@ -0,0 +1,267 @@
import type { AplicacionPago, NotaPlan, Pago, PlanPago, StatusPago, StatusPlan } from '../types'
import { uuid } from './uuid'
/** Redondeo monetario a 2 decimales */
export const round2 = (x: number): number => Math.round(x * 100) / 100
/** Tolerancia para comparaciones de dinero (centavos) */
export const EPS = 0.01
const gte = (a: number, b: number): boolean => a >= b - EPS
/** Parsea 'YYYY-MM-DD' como fecha LOCAL (nunca `new Date(iso)` a secas, que interpreta UTC) */
export function parseFechaLocal(iso: string): Date {
return new Date(`${iso}T00:00:00`)
}
function aISO(fecha: Date): string {
const y = fecha.getFullYear()
const m = String(fecha.getMonth() + 1).padStart(2, '0')
const d = String(fecha.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
/** Último día del mes (mes 0-based) */
function ultimoDiaDelMes(anio: number, mes: number): number {
return new Date(anio, mes + 1, 0).getDate()
}
/**
* Suma `meses` conservando el día del mes; si el mes destino no tiene ese día,
* usa su último día (p. ej. 31 de enero + 1 mes → 28/29 de febrero).
*/
export function sumarMeses(fecha: Date, meses: number): Date {
const totalMeses = fecha.getMonth() + meses
const anio = fecha.getFullYear() + Math.floor(totalMeses / 12)
const mes = ((totalMeses % 12) + 12) % 12
const dia = Math.min(fecha.getDate(), ultimoDiaDelMes(anio, mes))
return new Date(anio, mes, dia)
}
/** Compara solo la parte de fecha (ignora horas) */
function compararFechas(a: Date, b: Date): number {
const da = new Date(a.getFullYear(), a.getMonth(), a.getDate()).getTime()
const db = new Date(b.getFullYear(), b.getMonth(), b.getDate()).getTime()
return da - db
}
export interface CalendarioGenerado {
mensualidad: number
pagos: Pago[]
}
/**
* Genera N pagos con vencimiento mensual a partir de `fechaInicio`.
* mensualidad = round2(engancheTotal / numPagos); el ÚLTIMO pago absorbe
* el residuo del redondeo para que la suma sea exactamente `engancheTotal`.
*/
export function generarCalendario(
fechaInicio: string,
engancheTotal: number,
numPagos: number,
): CalendarioGenerado {
if (numPagos < 1) throw new Error('numPagos debe ser al menos 1')
if (engancheTotal <= 0) throw new Error('engancheTotal debe ser mayor a 0')
const inicio = parseFechaLocal(fechaInicio)
const mensualidad = round2(engancheTotal / numPagos)
const pagos: Pago[] = []
for (let i = 0; i < numPagos; i++) {
const monto =
i < numPagos - 1 ? mensualidad : round2(engancheTotal - mensualidad * (numPagos - 1))
pagos.push({
id: uuid(),
numPago: i + 1,
fechaVencimiento: aISO(sumarMeses(inicio, i)),
monto,
montoPagado: 0,
aplicaciones: [],
})
}
return { mensualidad, pagos }
}
/**
* Estado de un pago a la fecha `hoy`.
* PAGADO si cubrió el monto; PARCIAL si tiene abonos sin cubrir; sin abonos:
* VENCIDO si hoy > fechaVencimiento, si no PENDIENTE.
* Decisión de negocio: un pago PARCIAL cuya fecha ya venció sigue siendo
* PARCIAL (tiene abono aplicado); el atraso se señala vía `diasAtraso`.
*/
export function statusPago(pago: Pago, hoy: Date = new Date()): StatusPago {
if (gte(pago.montoPagado, pago.monto)) return 'PAGADO'
if (pago.montoPagado > EPS) return 'PARCIAL'
return compararFechas(hoy, parseFechaLocal(pago.fechaVencimiento)) > 0 ? 'VENCIDO' : 'PENDIENTE'
}
/** Saldo pendiente del plan: suma(monto - montoPagado) */
export function saldoPlan(plan: PlanPago): number {
return round2(plan.pagos.reduce((acc, p) => acc + (p.monto - p.montoPagado), 0))
}
/** Total cobrado (suma de lo aplicado a todos los pagos) */
export function pagadoTotal(plan: PlanPago): number {
return round2(plan.pagos.reduce((acc, p) => acc + p.montoPagado, 0))
}
/** Fracción de avance 0..1 (pagado / engancheTotal) */
export function avancePlan(plan: PlanPago): number {
return plan.engancheTotal > 0 ? pagadoTotal(plan) / plan.engancheTotal : 0
}
const restante = (p: Pago): number => round2(p.monto - p.montoPagado)
const estaCubierto = (p: Pago): boolean => gte(p.montoPagado, p.monto)
/**
* Primer pago no cubierto con fecha >= hoy; si no hay, el vencido más antiguo.
* Devuelve null si el plan está cubierto por completo.
*/
export function proximoVencimiento(plan: PlanPago, hoy: Date = new Date()): Pago | null {
const noCubiertos = plan.pagos.filter((p) => !estaCubierto(p))
const futuro = noCubiertos.find((p) => compararFechas(parseFechaLocal(p.fechaVencimiento), hoy) >= 0)
if (futuro) return futuro
const vencidos = noCubiertos.sort(
(a, b) => parseFechaLocal(a.fechaVencimiento).getTime() - parseFechaLocal(b.fechaVencimiento).getTime(),
)
return vencidos[0] ?? null
}
/** Días naturales hasta el próximo vencimiento; null si el plan está cubierto */
export function diasParaProximoVencimiento(plan: PlanPago, hoy: Date = new Date()): number | null {
const prox = proximoVencimiento(plan, hoy)
if (!prox) return null
return Math.round(compararFechas(parseFechaLocal(prox.fechaVencimiento), hoy) / 86400000)
}
/** Días de atraso del pago vencido más antiguo (0 si no hay vencidos sin abono) */
export function diasAtraso(plan: PlanPago, hoy: Date = new Date()): number {
const vencidos = plan.pagos
.filter((p) => statusPago(p, hoy) === 'VENCIDO')
.sort(
(a, b) =>
parseFechaLocal(a.fechaVencimiento).getTime() - parseFechaLocal(b.fechaVencimiento).getTime(),
)
const primero = vencidos[0]
if (!primero) return 0
return Math.round(compararFechas(hoy, parseFechaLocal(primero.fechaVencimiento)) / 86400000)
}
/** Status derivado: CANCELADO solo manual; LIQUIDADO si saldo == 0; si no, ACTIVO */
export function statusPlan(plan: PlanPago): StatusPlan {
if (plan.status === 'CANCELADO') return 'CANCELADO'
return saldoPlan(plan) <= EPS ? 'LIQUIDADO' : 'ACTIVO'
}
/** Al corriente = sin pagos VENCIDOS */
export function alCorriente(plan: PlanPago, hoy: Date = new Date()): boolean {
return !plan.pagos.some((p) => statusPago(p, hoy) === 'VENCIDO')
}
/**
* Mora simple de un pago: restante × (tasa% / 100) / 30 × días vencidos.
* Solo aplica a pagos vencidos con saldo; un PARCIAL vencido también genera
* mora sobre su restante. Tasa 0 (o negativa) = sin mora.
*/
export function moraDePago(pago: Pago, tasaMoraMensual: number, hoy: Date = new Date()): number {
if (tasaMoraMensual <= 0) return 0
const falta = restante(pago)
if (falta <= EPS) return 0
const diasVencido = Math.round(compararFechas(hoy, parseFechaLocal(pago.fechaVencimiento)) / 86400000)
if (diasVencido <= 0) return 0
return round2((falta * (tasaMoraMensual / 100) / 30) * diasVencido)
}
/** Mora acumulada del plan a la fecha `hoy` (usa plan.tasaMoraMensual ?? 0) */
export function totalMora(plan: PlanPago, hoy: Date = new Date()): number {
const tasa = plan.tasaMoraMensual ?? 0
return round2(plan.pagos.reduce((acc, p) => acc + moraDePago(p, tasa, hoy), 0))
}
export interface CobranzaItem {
plan: PlanPago
pago: Pago
/** Días de atraso (0 si aún no vence) */
atraso: number
}
/**
* Lista plana de "cobranza del día" a través de todos los planes ACTIVOS:
* pagos vencidos (incluye PARCIAL vencido) más los que vencen en los próximos
* `diasAnticipacion` días. Orden de urgencia: mayor atraso primero; a igual
* atraso, el vencimiento más próximo primero.
*/
export function cobranzaDelDia(
planes: PlanPago[],
hoy: Date = new Date(),
diasAnticipacion = 7,
): CobranzaItem[] {
const limite = new Date(hoy.getFullYear(), hoy.getMonth(), hoy.getDate() + diasAnticipacion)
const items: CobranzaItem[] = []
for (const plan of planes) {
if (statusPlan(plan) !== 'ACTIVO') continue
for (const pago of plan.pagos) {
if (estaCubierto(pago)) continue
if (compararFechas(parseFechaLocal(pago.fechaVencimiento), limite) > 0) continue
const atraso = Math.max(
0,
Math.round(compararFechas(hoy, parseFechaLocal(pago.fechaVencimiento)) / 86400000),
)
items.push({ plan, pago, atraso })
}
}
return items.sort((a, b) => {
if (a.atraso !== b.atraso) return b.atraso - a.atraso
return compararFechas(
parseFechaLocal(a.pago.fechaVencimiento),
parseFechaLocal(b.pago.fechaVencimiento),
)
})
}
/**
* Agrega una nota a la bitácora del plan (copia inmutable).
* El texto es requerido (mín. 3 caracteres).
*/
export function agregarNotaAPlan(plan: PlanPago, texto: string): PlanPago {
const limpio = texto.trim()
if (limpio.length < 3) {
throw new Error('La nota debe tener al menos 3 caracteres')
}
const nota: NotaPlan = { id: uuid(), fecha: new Date().toISOString(), texto: limpio }
return { ...plan, notas: [...(plan.notas ?? []), nota] }
}
export interface AbonoInput {
pagoId: string
fechaPago: string // ISO
monto: number
referencia: string
}
/**
* Aplica un abono al pago indicado y devuelve una copia del plan actualizada.
* Rechaza montos <= 0 y abonos que excedan el restante del pago.
*/
export function registrarAbono(plan: PlanPago, abono: AbonoInput): PlanPago {
if (round2(abono.monto) <= 0) {
throw new Error('El abono debe ser mayor a 0')
}
const pago = plan.pagos.find((p) => p.id === abono.pagoId)
if (!pago) throw new Error('Pago no encontrado en el plan')
const falta = restante(pago)
// La validación es estricta al centavo: un abono nunca puede exceder el restante
if (round2(abono.monto - falta) > 0) {
throw new Error(`El abono excede el restante del pago (${falta.toFixed(2)})`)
}
const aplicacion: AplicacionPago = {
id: uuid(),
fechaPago: abono.fechaPago,
monto: round2(abono.monto),
referencia: abono.referencia,
}
const pagos = plan.pagos.map((p) =>
p.id === abono.pagoId
? { ...p, montoPagado: round2(p.montoPagado + abono.monto), aplicaciones: [...p.aplicaciones, aplicacion] }
: p,
)
return { ...plan, pagos }
}

View File

@@ -0,0 +1,36 @@
const formatoMXN = new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
})
/** Formato monetario MXN, p. ej. $4,579,000.00 */
export function formatoMoneda(valor: number): string {
return formatoMXN.format(valor)
}
/** Fracción (0.75) a texto porcentaje (75%) */
export function formatoPorcentaje(fraccion: number): string {
return `${(fraccion * 100).toLocaleString('es-MX', { maximumFractionDigits: 2 })}%`
}
/** Fecha en formato largo es-MX: "20 de agosto de 2026" */
export function formatoFechaLarga(fecha: Date): string {
return fecha.toLocaleDateString('es-MX', { day: 'numeric', month: 'long', year: 'numeric' })
}
/** ISO 'YYYY-MM-DD' a fecha legible corta: "08/10/2027" */
export function formatoFechaCorta(iso: string): string {
return new Date(`${iso}T00:00:00`).toLocaleDateString('es-MX', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
/** Fecha ISO del día (para nombre de archivo): 'YYYY-MM-DD' */
export function fechaISOHoy(fecha: Date = new Date()): string {
const y = fecha.getFullYear()
const m = String(fecha.getMonth() + 1).padStart(2, '0')
const d = String(fecha.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import type { PlanPago } from '../../types'
import { generarCalendario, registrarAbono } from '../cobranza'
import { crearDocumento } from './estadoCuenta'
function planFixture(): PlanPago {
const { mensualidad, pagos } = generarCalendario('2026-01-15', 100000, 3)
let plan: PlanPago = {
id: 'plan-fixture',
ventaRef: 'VTA-2026-001',
clienteNombre: 'Cliente Fixture',
unidadCodigo: 'U-101',
proyecto: 'SENDA',
fechaInicio: '2026-01-15',
engancheTotal: 100000,
numPagos: 3,
mensualidad,
status: 'ACTIVO',
pagos,
notas: [],
createdAt: '2026-01-01T00:00:00.000Z',
}
plan = registrarAbono(plan, {
pagoId: plan.pagos[0].id,
fechaPago: '2026-01-15',
monto: plan.pagos[0].monto,
referencia: 'SPEI-001',
})
return plan
}
describe('estadoCuenta PDF', () => {
it('genera el documento con nombre de archivo correcto', () => {
const hoy = new Date('2026-02-20T12:00:00')
const { doc, nombreArchivo } = crearDocumento(planFixture(), hoy)
expect(nombreArchivo).toBe('estado_cuenta_U-101_2026-02-20.pdf')
expect(doc.getNumberOfPages()).toBeGreaterThanOrEqual(1)
// smoke: el contenido interno es un PDF válido
expect(doc.output('datauristring')).toContain('application/pdf')
})
it('smoke: genera el documento con fila de mora cuando hay tasa y pagos vencidos', () => {
const hoy = new Date('2026-04-20T12:00:00') // pagos 2 y 3 vencidos
const plan: PlanPago = { ...planFixture(), tasaMoraMensual: 2 }
const { doc } = crearDocumento(plan, hoy)
expect(doc.getNumberOfPages()).toBeGreaterThanOrEqual(1)
expect(doc.output('datauristring')).toContain('application/pdf')
})
})

View File

@@ -0,0 +1,112 @@
import { jsPDF } from 'jspdf'
import autoTable from 'jspdf-autotable'
import type { PlanPago } from '../../types'
import { avancePlan, pagadoTotal, round2, saldoPlan, statusPago, totalMora } from '../cobranza'
import { fechaISOHoy, formatoFechaCorta, formatoFechaLarga, formatoMoneda, formatoPorcentaje } from '../formatters'
const MARGEN = 14
// jspdf-autotable expone finalY en doc.lastAutoTable en runtime; sus tipos no lo declaran
type DocConTabla = jsPDF & { lastAutoTable: { finalY: number } }
const finalY = (doc: jsPDF): number => (doc as DocConTabla).lastAutoTable.finalY
export interface DocumentoPdf {
doc: jsPDF
nombreArchivo: string
}
/** Construye el estado de cuenta del plan de enganche (sin descargarlo) */
export function crearDocumento(plan: PlanPago, hoy: Date = new Date()): DocumentoPdf {
const doc = new jsPDF()
let y = MARGEN
// 1. Encabezado
doc.setFontSize(14)
doc.setFont('helvetica', 'bold')
doc.text('FRASA DESARROLLOS', 105, y, { align: 'center' })
y += 7
doc.setFontSize(11)
doc.text('ESTADO DE CUENTA — PLAN DE ENGANCHE', 105, y, { align: 'center' })
y += 6
doc.setFontSize(9)
doc.setFont('helvetica', 'normal')
doc.text(`Fecha de emisión: ${formatoFechaLarga(hoy)}`, 105, y, { align: 'center' })
y += 8
// 2. Datos del plan
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 8 },
head: [['CLIENTE', 'UNIDAD', 'PROYECTO', 'VENTA', 'ENGANCHE', 'MENSUALIDAD', 'PAGOS']],
body: [[
plan.clienteNombre,
plan.unidadCodigo,
plan.proyecto,
plan.ventaRef || '—',
formatoMoneda(plan.engancheTotal),
formatoMoneda(plan.mensualidad),
String(plan.numPagos),
]],
headStyles: { fillColor: [4, 120, 87] },
})
y = finalY(doc) + 6
// 3. Tabla de pagos
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 8 },
head: [['#', 'VENCIMIENTO', 'MONTO', 'PAGADO', 'RESTANTE', 'STATUS', 'REFERENCIAS']],
body: plan.pagos.map((p) => [
String(p.numPago),
formatoFechaCorta(p.fechaVencimiento),
formatoMoneda(p.monto),
formatoMoneda(p.montoPagado),
formatoMoneda(round2(p.monto - p.montoPagado)),
statusPago(p, hoy),
p.aplicaciones
.map((a) => `${a.referencia} (${formatoFechaCorta(a.fechaPago)})`)
.join('\n') || '—',
]),
headStyles: { fillColor: [4, 120, 87] },
})
y = finalY(doc) + 6
// 4. Totales
const pagado = pagadoTotal(plan)
const saldo = saldoPlan(plan)
const mora = totalMora(plan, hoy)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 9 },
body: [
['TOTAL PAGADO', formatoMoneda(pagado)],
['SALDO PENDIENTE', formatoMoneda(saldo)],
...(mora > 0
? [
['MORA ACUMULADA', formatoMoneda(mora)],
['TOTAL A PAGAR', formatoMoneda(round2(saldo + mora))],
]
: []),
['% AVANCE', formatoPorcentaje(avancePlan(plan))],
],
theme: 'plain',
columnStyles: { 0: { fontStyle: 'bold', cellWidth: 60 }, 1: { halign: 'left' } },
})
y = finalY(doc) + 10
// 5. Pie
doc.setFontSize(8)
doc.setFont('helvetica', 'normal')
doc.text('Documento informativo. Para aclaraciones contacta a tu asesor de ventas.', MARGEN, y)
return { doc, nombreArchivo: `estado_cuenta_${plan.unidadCodigo}_${fechaISOHoy(hoy)}.pdf` }
}
/** Genera y descarga el estado de cuenta en PDF */
export function descargar(plan: PlanPago): void {
const { doc, nombreArchivo } = crearDocumento(plan)
doc.save(nombreArchivo)
}

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import type { PlanPago } from '../../types'
import { generarCalendario, registrarAbono } from '../cobranza'
import { crearDocumento, folioRecibo } from './reciboAbono'
function planConAbono(): PlanPago {
const { mensualidad, pagos } = generarCalendario('2026-01-15', 100000, 3)
const plan: PlanPago = {
id: 'plan-fixture',
ventaRef: 'VTA-2026-001',
clienteNombre: 'Cliente Fixture',
clienteTelefono: '55 0000 0000',
unidadCodigo: 'U-101',
proyecto: 'SENDA',
fechaInicio: '2026-01-15',
engancheTotal: 100000,
numPagos: 3,
mensualidad,
status: 'ACTIVO',
pagos,
notas: [],
createdAt: '2026-01-01T00:00:00.000Z',
}
return registrarAbono(plan, {
pagoId: plan.pagos[0].id,
fechaPago: '2026-01-15',
monto: 5000,
referencia: 'SPEI-001',
})
}
describe('reciboAbono PDF', () => {
it('el folio son los primeros 8 chars del id de la aplicación en mayúsculas', () => {
const plan = planConAbono()
const aplicacion = plan.pagos[0].aplicaciones[0]
expect(folioRecibo(aplicacion)).toBe(aplicacion.id.slice(0, 8).toUpperCase())
expect(folioRecibo({ ...aplicacion, id: 'abcdef12-3456-7890' })).toBe('ABCDEF12')
})
it('genera el documento con nombre de archivo correcto', () => {
const plan = planConAbono()
const pago = plan.pagos[0]
const aplicacion = pago.aplicaciones[0]
const { doc, nombreArchivo } = crearDocumento(plan, pago, aplicacion, new Date('2026-02-20T12:00:00'))
expect(nombreArchivo).toBe(`recibo_U-101_${folioRecibo(aplicacion)}.pdf`)
expect(doc.getNumberOfPages()).toBeGreaterThanOrEqual(1)
// smoke: el contenido interno es un PDF válido
expect(doc.output('datauristring')).toContain('application/pdf')
})
})

View File

@@ -0,0 +1,106 @@
import { jsPDF } from 'jspdf'
import autoTable from 'jspdf-autotable'
import type { AplicacionPago, Pago, PlanPago } from '../../types'
import { round2 } from '../cobranza'
import { formatoFechaCorta, formatoFechaLarga, formatoMoneda } from '../formatters'
import type { DocumentoPdf } from './estadoCuenta'
const MARGEN = 14
/** Folio corto del recibo: primeros 8 caracteres del id de la aplicación, en mayúsculas */
export function folioRecibo(aplicacion: AplicacionPago): string {
return aplicacion.id.slice(0, 8).toUpperCase()
}
/**
* Construye el recibo de pago de una aplicación (abono) ya registrada.
* El restante del pago y el saldo del plan se calculan acumulando las
* aplicaciones registradas hasta esa aplicación (inclusive), de modo que el
* recibo refleja los montos vigentes justo tras el abono.
*/
export function crearDocumento(
plan: PlanPago,
pago: Pago,
aplicacion: AplicacionPago,
hoy: Date = new Date(),
): DocumentoPdf {
const folio = folioRecibo(aplicacion)
const doc = new jsPDF()
let y = MARGEN
// Aplicado acumulado (plan y pago) hasta la aplicación indicada, en orden de registro
let aplicadoPlan = 0
let aplicadoPago = 0
let encontrada = false
for (const p of plan.pagos) {
for (const a of p.aplicaciones) {
aplicadoPlan += a.monto
if (p.id === pago.id) aplicadoPago += a.monto
if (a.id === aplicacion.id) {
encontrada = true
break
}
}
if (encontrada) break
}
const saldoTrasAbono = round2(plan.engancheTotal - aplicadoPlan)
const restanteTrasAbono = round2(pago.monto - aplicadoPago)
// 1. Encabezado
doc.setFontSize(14)
doc.setFont('helvetica', 'bold')
doc.text('FRASA DESARROLLOS', 105, y, { align: 'center' })
y += 7
doc.setFontSize(11)
doc.text('RECIBO DE PAGO', 105, y, { align: 'center' })
y += 6
doc.setFontSize(9)
doc.setFont('helvetica', 'normal')
doc.text(`Folio: ${folio} · Fecha de emisión: ${formatoFechaLarga(hoy)}`, 105, y, { align: 'center' })
y += 8
// 2. Datos del recibo
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 9 },
body: [
['CLIENTE', plan.clienteNombre],
['UNIDAD', plan.unidadCodigo],
['PROYECTO', plan.proyecto],
['PAGO', `#${pago.numPago} (vencimiento ${formatoFechaCorta(pago.fechaVencimiento)})`],
['FECHA DE PAGO', formatoFechaCorta(aplicacion.fechaPago)],
['MONTO DEL ABONO', formatoMoneda(aplicacion.monto)],
['REFERENCIA', aplicacion.referencia || '—'],
['RESTANTE DEL PAGO', formatoMoneda(restanteTrasAbono)],
['SALDO DEL PLAN TRAS EL ABONO', formatoMoneda(saldoTrasAbono)],
],
theme: 'plain',
columnStyles: { 0: { fontStyle: 'bold', cellWidth: 70 }, 1: { halign: 'left' } },
})
y = (doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable.finalY + 10
// 3. Leyenda informativa
doc.setFontSize(8)
doc.text(
'Recibo informativo del abono aplicado al plan de enganche. No sustituye la factura ni el comprobante fiscal correspondiente.',
MARGEN,
y,
{ maxWidth: 180 },
)
y += 24
// 4. Línea de firma
doc.setFontSize(9)
doc.text('_______________________________', 105, y, { align: 'center' })
y += 5
doc.text(`Recibió — ${plan.clienteNombre}`, 105, y, { align: 'center' })
return { doc, nombreArchivo: `recibo_${plan.unidadCodigo}_${folio}.pdf` }
}
/** Genera y descarga el recibo de pago en PDF */
export function descargarRecibo(plan: PlanPago, pago: Pago, aplicacion: AplicacionPago): void {
const { doc, nombreArchivo } = crearDocumento(plan, pago, aplicacion)
doc.save(nombreArchivo)
}

View File

@@ -0,0 +1,14 @@
/**
* Genera un UUID v4. crypto.randomUUID solo existe en contextos seguros
* (HTTPS o localhost); al servir por IP en HTTP hay que usar el fallback.
*/
export function uuid(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0
const v = c === 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
}

View File

@@ -0,0 +1,29 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter, Route, Routes } from 'react-router-dom'
import '@fontsource/inter/300.css'
import '@fontsource/inter/400.css'
import '@fontsource/inter/500.css'
import '@fontsource/inter/700.css'
import './index.css'
import Layout from './components/Layout'
import DashboardPage from './pages/DashboardPage'
import HoyPage from './pages/HoyPage'
import PlanDetallePage from './pages/PlanDetallePage'
import NuevoPlanPage from './pages/NuevoPlanPage'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter basename="/pay">
<Layout>
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/hoy" element={<HoyPage />} />
<Route path="/planes/:id" element={<PlanDetallePage />} />
<Route path="/nuevo" element={<NuevoPlanPage />} />
<Route path="*" element={<DashboardPage />} />
</Routes>
</Layout>
</BrowserRouter>
</StrictMode>,
)

View File

@@ -0,0 +1,359 @@
import { useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import type { PlanPago, StatusPlan } from '../types'
import { cargarPlanes } from '../services/repository'
import {
alCorriente,
avancePlan,
diasAtraso,
parseFechaLocal,
proximoVencimiento,
round2,
saldoPlan,
statusPlan,
sumarMeses,
} from '../lib/cobranza'
import { formatoFechaCorta, formatoMoneda, formatoPorcentaje } from '../lib/formatters'
import KpiCard from '../components/KpiCard'
import SemaforoDot, { semaforoPlan } from '../components/Semaforo'
const MESES_CORTOS = ['ene', 'feb', 'mar', 'abr', 'may', 'jun', 'jul', 'ago', 'sep', 'oct', 'nov', 'dic']
const COLORES_PIE = ['#1B4332', '#d4d4d4']
type ColumnaOrden = 'saldo' | 'vencimiento' | 'atraso' | 'avance'
const COLUMNAS_ORDEN: ColumnaOrden[] = ['saldo', 'vencimiento', 'atraso', 'avance']
function esColumnaOrden(valor: string | null): valor is ColumnaOrden {
return valor !== null && (COLUMNAS_ORDEN as string[]).includes(valor)
}
function claveMes(fecha: Date): string {
return `${fecha.getFullYear()}-${String(fecha.getMonth() + 1).padStart(2, '0')}`
}
export default function DashboardPage() {
const [planes] = useState<PlanPago[]>(() => cargarPlanes())
// Filtros y ordenamiento persistidos en la URL para que sean compartibles
const [searchParams, setSearchParams] = useSearchParams()
const busqueda = searchParams.get('q') ?? ''
const filtroStatus = (searchParams.get('status') ?? 'TODOS') as StatusPlan | 'TODOS'
const filtroProyecto = searchParams.get('proyecto') ?? 'TODOS'
const paramOrden = searchParams.get('sort')
const orden: ColumnaOrden | null = esColumnaOrden(paramOrden) ? paramOrden : null
const dir: 'asc' | 'desc' = searchParams.get('dir') === 'desc' ? 'desc' : 'asc'
const hoy = useMemo(() => new Date(), [])
const actualizarParams = (cambios: Record<string, string | null>) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev)
for (const [clave, valor] of Object.entries(cambios)) {
if (valor === null || valor === '') next.delete(clave)
else next.set(clave, valor)
}
return next
},
{ replace: true },
)
}
const alternarOrden = (columna: ColumnaOrden) => {
// Click sobre la columna activa alterna asc/desc; sobre otra, empieza en asc
if (orden === columna) {
actualizarParams({ sort: columna, dir: dir === 'asc' ? 'desc' : 'asc' })
} else {
actualizarParams({ sort: columna, dir: 'asc' })
}
}
const proyectos = useMemo(
() => [...new Set(planes.map((p) => p.proyecto))].sort((a, b) => a.localeCompare(b)),
[planes],
)
const derivados = useMemo(
() =>
planes.map((plan) => ({
plan,
status: statusPlan(plan),
saldo: saldoPlan(plan),
avance: avancePlan(plan),
corriente: alCorriente(plan, hoy),
proximo: proximoVencimiento(plan, hoy),
atraso: diasAtraso(plan, hoy),
semaforo: semaforoPlan(plan, hoy),
})),
[planes, hoy],
)
const activos = derivados.filter((d) => d.status === 'ACTIVO')
const alCorrienteCount = activos.filter((d) => d.corriente).length
const conAtraso = activos.length - alCorrienteCount
const saldoTotal = round2(
derivados.filter((d) => d.status !== 'CANCELADO').reduce((acc, d) => acc + d.saldo, 0),
)
const mesActual = claveMes(hoy)
const cobradoEsteMes = round2(
planes
.flatMap((p) => p.pagos.flatMap((pg) => pg.aplicaciones))
.filter((a) => a.fechaPago.startsWith(mesActual))
.reduce((acc, a) => acc + a.monto, 0),
)
const datosPie = [
{ name: 'Al corriente', value: alCorrienteCount },
{ name: 'Con atraso', value: conAtraso },
]
const datosBarras = useMemo(() => {
const aplicaciones = planes.flatMap((p) => p.pagos.flatMap((pg) => pg.aplicaciones))
const meses: { clave: string; etiqueta: string }[] = []
for (let i = 5; i >= 0; i--) {
const f = sumarMeses(new Date(hoy.getFullYear(), hoy.getMonth(), 1), -i)
meses.push({ clave: claveMes(f), etiqueta: MESES_CORTOS[f.getMonth()] })
}
return meses.map(({ clave, etiqueta }) => ({
mes: etiqueta,
cobrado: round2(
aplicaciones.filter((a) => a.fechaPago.startsWith(clave)).reduce((acc, a) => acc + a.monto, 0),
),
}))
}, [planes, hoy])
const filtrados = derivados.filter((d) => {
if (filtroStatus !== 'TODOS' && d.status !== filtroStatus) return false
if (filtroProyecto !== 'TODOS' && d.plan.proyecto !== filtroProyecto) return false
const texto = busqueda.trim().toLowerCase()
if (!texto) return true
return (
d.plan.clienteNombre.toLowerCase().includes(texto) ||
d.plan.unidadCodigo.toLowerCase().includes(texto)
)
})
const valorOrden = (d: (typeof filtrados)[number]): number => {
switch (orden) {
case 'saldo':
return d.saldo
case 'vencimiento':
// Planes cubiertos (sin próximo vencimiento) siempre al final
return d.proximo ? parseFechaLocal(d.proximo.fechaVencimiento).getTime() : Number.MAX_SAFE_INTEGER
case 'atraso':
return d.atraso
case 'avance':
return d.avance
default:
return 0
}
}
const ordenados = (() => {
if (!orden) return filtrados
const factor = dir === 'desc' ? -1 : 1
return [...filtrados].sort((a, b) => (valorOrden(a) - valorOrden(b)) * factor)
})()
const encabezadoOrdenable = (columna: ColumnaOrden, titulo: string, alineadoDerecha = false) => (
<th scope="col" className={`py-3 pr-4 ${alineadoDerecha ? 'text-right' : ''}`}>
<button
type="button"
onClick={() => alternarOrden(columna)}
aria-label={`Ordenar por ${titulo}`}
className="inline-flex items-center gap-1 font-bold uppercase tracking-widest transition-colors duration-150 hover:text-neutral-900"
>
{titulo}
{orden === columna && <span aria-hidden="true">{dir === 'asc' ? '↑' : '↓'}</span>}
</button>
</th>
)
return (
<div className="space-y-16">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-light text-neutral-900">Módulo Cobranza — Planes de enganche</h1>
<Link
to="/nuevo"
className="bg-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:opacity-90"
>
Nuevo plan
</Link>
</div>
{/* KPIs */}
<div className="grid grid-cols-2 gap-6 lg:grid-cols-4">
<KpiCard titulo="Planes activos" valor={activos.length} />
<KpiCard
titulo="Al corriente / con atraso"
valor={`${alCorrienteCount} / ${conAtraso}`}
detalle="Solo planes activos"
/>
<KpiCard titulo="Saldo por cobrar" valor={formatoMoneda(saldoTotal)} />
<KpiCard titulo="Cobrado este mes" valor={formatoMoneda(cobradoEsteMes)} />
</div>
{/* Gráficas */}
<div className="grid gap-6 lg:grid-cols-2">
<div className="border border-neutral-200 bg-white p-6">
<h2 className="mb-6 text-xs font-bold uppercase tracking-widest text-neutral-500">
Al corriente vs vencidos
</h2>
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie
data={datosPie}
dataKey="value"
nameKey="name"
innerRadius={50}
outerRadius={80}
strokeWidth={0}
label
>
{datosPie.map((_, i) => (
<Cell key={i} fill={COLORES_PIE[i % COLORES_PIE.length]} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
<div className="border border-neutral-200 bg-white p-6">
<h2 className="mb-6 text-xs font-bold uppercase tracking-widest text-neutral-500">
Cobrado por mes (últimos 6 meses)
</h2>
<ResponsiveContainer width="100%" height={240}>
<BarChart data={datosBarras}>
<CartesianGrid stroke="#e5e5e5" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="mes" tickLine={false} axisLine={{ stroke: '#e5e5e5' }} />
<YAxis
tickLine={false}
axisLine={false}
tickFormatter={(v: number) => `$${(v / 1000).toLocaleString('es-MX')}k`}
/>
<Tooltip formatter={(v) => formatoMoneda(Number(v))} cursor={{ fill: '#fafafa' }} />
<Bar dataKey="cobrado" fill="#1B4332" name="Cobrado" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Filtros */}
<div className="flex flex-wrap items-center gap-4">
<label htmlFor="filtro-busqueda" className="sr-only">
Buscar por cliente o unidad
</label>
<input
id="filtro-busqueda"
type="search"
value={busqueda}
onChange={(e) => actualizarParams({ q: e.target.value })}
placeholder="Buscar por cliente o unidad…"
className="w-64 border border-neutral-300 bg-white px-3 py-2.5 text-sm transition-colors duration-150 focus:border-neutral-900 focus:outline-none"
/>
<label htmlFor="filtro-status" className="sr-only">
Filtrar por status
</label>
<select
id="filtro-status"
value={filtroStatus}
onChange={(e) => actualizarParams({ status: e.target.value === 'TODOS' ? null : e.target.value })}
className="border border-neutral-300 bg-white px-3 py-2.5 text-sm transition-colors duration-150 focus:border-neutral-900 focus:outline-none"
>
<option value="TODOS">Todos los status</option>
<option value="ACTIVO">ACTIVO</option>
<option value="LIQUIDADO">LIQUIDADO</option>
<option value="CANCELADO">CANCELADO</option>
</select>
<label htmlFor="filtro-proyecto" className="sr-only">
Filtrar por proyecto
</label>
<select
id="filtro-proyecto"
value={filtroProyecto}
onChange={(e) => actualizarParams({ proyecto: e.target.value === 'TODOS' ? null : e.target.value })}
className="border border-neutral-300 bg-white px-3 py-2.5 text-sm transition-colors duration-150 focus:border-neutral-900 focus:outline-none"
>
<option value="TODOS">Todos los proyectos</option>
{proyectos.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</div>
{/* Tabla de planes */}
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-neutral-900 text-xs font-bold uppercase tracking-widest text-neutral-500">
<th scope="col" className="py-3 pr-4">Cliente</th>
<th scope="col" className="py-3 pr-4">Unidad</th>
<th scope="col" className="py-3 pr-4">Proyecto</th>
{encabezadoOrdenable('avance', '% pagado')}
{encabezadoOrdenable('vencimiento', 'Próx. vencimiento')}
{encabezadoOrdenable('saldo', 'Saldo', true)}
{encabezadoOrdenable('atraso', 'Días atraso', true)}
<th scope="col" className="py-3">Estado</th>
</tr>
</thead>
<tbody>
{ordenados.map((d) => (
<tr key={d.plan.id} className="border-b border-neutral-200 transition-colors duration-150 hover:bg-neutral-50">
<td className="py-3 pr-4">
<Link to={`/planes/${d.plan.id}`} className="font-medium text-accent hover:underline">
{d.plan.clienteNombre}
</Link>
</td>
<td className="py-3 pr-4">{d.plan.unidadCodigo}</td>
<td className="py-3 pr-4">{d.plan.proyecto}</td>
<td className="py-3 pr-4">
<div className="flex items-center gap-2">
<div className="h-1 w-24 bg-neutral-200">
<div
className="h-full bg-accent"
style={{ width: `${Math.min(100, d.avance * 100)}%` }}
/>
</div>
<span className="text-xs text-neutral-500">{formatoPorcentaje(d.avance)}</span>
</div>
</td>
<td className="py-3 pr-4">
{d.proximo ? formatoFechaCorta(d.proximo.fechaVencimiento) : '—'}
</td>
<td className="py-3 pr-4 text-right font-medium">{formatoMoneda(d.saldo)}</td>
<td className="py-3 pr-4 text-right">
{d.atraso > 0 ? <span className="text-red-700">{d.atraso}</span> : '0'}
</td>
<td className="py-3">
<SemaforoDot valor={d.semaforo} />
</td>
</tr>
))}
{ordenados.length === 0 && (
<tr>
<td colSpan={8} className="py-8 text-center text-neutral-500">
No hay planes que coincidan con el filtro.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,102 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import type { PlanPago } from '../types'
import { cargarPlanes } from '../services/repository'
import { cobranzaDelDia, round2, statusPago } from '../lib/cobranza'
import { formatoFechaCorta, formatoMoneda } from '../lib/formatters'
import KpiCard from '../components/KpiCard'
/** Vista "Cobranza del día": vencidos + por vencer en 7 días, a través de todos los planes activos */
export default function HoyPage() {
const [planes] = useState<PlanPago[]>(() => cargarPlanes())
const hoy = useMemo(() => new Date(), [])
const items = useMemo(() => cobranzaDelDia(planes, hoy), [planes, hoy])
const vencidos = items.filter((i) => i.atraso > 0)
const porVencer = items.length - vencidos.length
const totalPorCobrar = round2(
items.reduce((acc, i) => acc + (i.pago.monto - i.pago.montoPagado), 0),
)
return (
<div className="space-y-16">
<div>
<h1 className="text-3xl font-light text-neutral-900">Cobranza del día</h1>
<p className="mt-1 text-sm text-neutral-500">
Pagos vencidos y vencimientos de los próximos 7 días, en todos los planes activos.
</p>
</div>
<div className="grid grid-cols-2 gap-6 lg:grid-cols-3">
<KpiCard titulo="Pagos vencidos" valor={vencidos.length} />
<KpiCard titulo="Por vencer (7 días)" valor={porVencer} />
<KpiCard titulo="Monto por cobrar" valor={formatoMoneda(totalPorCobrar)} />
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-neutral-900 text-xs font-bold uppercase tracking-widest text-neutral-500">
<th scope="col" className="py-3 pr-4">Cliente</th>
<th scope="col" className="py-3 pr-4">Teléfono</th>
<th scope="col" className="py-3 pr-4">Unidad</th>
<th scope="col" className="py-3 pr-4"># pago</th>
<th scope="col" className="py-3 pr-4">Vencimiento</th>
<th scope="col" className="py-3 pr-4 text-right">Monto</th>
<th scope="col" className="py-3 pr-4 text-right">Restante</th>
<th scope="col" className="py-3 pr-4 text-right">Días atraso</th>
<th scope="col" className="py-3">Plan</th>
</tr>
</thead>
<tbody>
{items.map(({ plan, pago, atraso }) => (
<tr
key={`${plan.id}-${pago.id}`}
className="border-b border-neutral-200 transition-colors duration-150 hover:bg-neutral-50"
>
<td className="py-3 pr-4 font-medium">{plan.clienteNombre}</td>
<td className="py-3 pr-4">
{plan.clienteTelefono ? (
<a href={`tel:${plan.clienteTelefono.replace(/\s/g, '')}`} className="text-accent hover:underline">
{plan.clienteTelefono}
</a>
) : (
<span className="text-neutral-400">—</span>
)}
</td>
<td className="py-3 pr-4">{plan.unidadCodigo}</td>
<td className="py-3 pr-4">{pago.numPago}</td>
<td className="py-3 pr-4">{formatoFechaCorta(pago.fechaVencimiento)}</td>
<td className="py-3 pr-4 text-right">{formatoMoneda(pago.monto)}</td>
<td className="py-3 pr-4 text-right font-medium">
{formatoMoneda(round2(pago.monto - pago.montoPagado))}
</td>
<td className="py-3 pr-4 text-right">
{atraso > 0 ? (
<span className="font-medium text-red-700">{atraso}</span>
) : (
<span className="text-amber-700">
{statusPago(pago, hoy) === 'PARCIAL' ? 'Parcial' : 'Por vencer'}
</span>
)}
</td>
<td className="py-3">
<Link to={`/planes/${plan.id}`} className="text-accent hover:underline">
Ver plan
</Link>
</td>
</tr>
))}
{items.length === 0 && (
<tr>
<td colSpan={9} className="py-8 text-center text-neutral-500">
No hay pagos vencidos ni vencimientos en los próximos 7 días.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,213 @@
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useNavigate } from 'react-router-dom'
import { crearPlan } from '../services/repository'
import { generarCalendario } from '../lib/cobranza'
import { fechaISOHoy, formatoFechaCorta, formatoMoneda } from '../lib/formatters'
const PROYECTOS = ['RESIDENCIAL MONTEBELLO', 'SENDA', 'DIAGONAL'] as const
const esquema = z.object({
clienteNombre: z.string().trim().min(3, 'Nombre del cliente requerido (mín. 3 caracteres)'),
clienteTelefono: z.string().trim().optional(),
unidadCodigo: z.string().trim().min(1, 'Código de unidad requerido'),
proyecto: z.enum(PROYECTOS),
ventaRef: z.string().trim(),
fechaInicio: z.string().min(1, 'Fecha de inicio requerida'),
engancheTotal: z
.number({ invalid_type_error: 'Captura un monto válido' })
.positive('El enganche debe ser mayor a 0'),
numPagos: z
.number({ invalid_type_error: 'Captura un número válido' })
.int('Debe ser un número entero')
.min(1, 'Mínimo 1 pago')
.max(36, 'Máximo 36 pagos'),
tasaMoraMensual: z.preprocess(
(v) => (v === '' || v === undefined || v === null ? undefined : Number(v)),
z
.number({ invalid_type_error: 'Captura una tasa válida' })
.min(0, 'La tasa no puede ser negativa')
.max(30, 'Tasa máxima 30%')
.optional(),
),
})
type NuevoPlanForm = z.infer<typeof esquema>
const claseInput =
'mt-1 w-full border border-neutral-300 bg-white px-3 py-2.5 text-sm transition-colors duration-150 focus:border-neutral-900 focus:outline-none'
const claseError = 'mt-1 text-xs text-red-700'
const claseLabel = 'block text-xs font-bold uppercase tracking-widest text-neutral-500'
export default function NuevoPlanPage() {
const navigate = useNavigate()
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<NuevoPlanForm>({
resolver: zodResolver(esquema),
defaultValues: {
clienteNombre: '',
unidadCodigo: '',
proyecto: 'SENDA',
ventaRef: '',
fechaInicio: fechaISOHoy(),
numPagos: 6,
},
})
const [fechaInicio, engancheTotal, numPagos] = watch(['fechaInicio', 'engancheTotal', 'numPagos'])
// Vista previa en vivo del calendario; solo si los valores son válidos
let vistaPrevia: ReturnType<typeof generarCalendario> | null = null
try {
if (fechaInicio && engancheTotal > 0 && Number.isInteger(numPagos) && numPagos >= 1 && numPagos <= 36) {
vistaPrevia = generarCalendario(fechaInicio, engancheTotal, numPagos)
}
} catch {
vistaPrevia = null
}
const guardar = (datos: NuevoPlanForm) => {
const plan = crearPlan(datos)
void navigate(`/planes/${plan.id}`)
}
return (
<div className="mx-auto max-w-3xl space-y-16">
<h1 className="text-3xl font-light text-neutral-900">Nuevo plan de enganche</h1>
<form
className="border border-neutral-200 bg-white p-8"
onSubmit={(e) => {
void handleSubmit(guardar)(e)
}}
>
<div className="grid gap-6 sm:grid-cols-2">
<div className="sm:col-span-2">
<label htmlFor="clienteNombre" className={claseLabel}>Cliente *</label>
<input id="clienteNombre" type="text" className={claseInput} {...register('clienteNombre')} />
{errors.clienteNombre && <p className={claseError}>{errors.clienteNombre.message}</p>}
</div>
<div>
<label htmlFor="clienteTelefono" className={claseLabel}>Teléfono de contacto</label>
<input
id="clienteTelefono"
type="tel"
className={claseInput}
placeholder="55 1234 5678"
{...register('clienteTelefono')}
/>
</div>
<div>
<label htmlFor="unidadCodigo" className={claseLabel}>Unidad *</label>
<input id="unidadCodigo" type="text" className={claseInput} placeholder="U-101" {...register('unidadCodigo')} />
{errors.unidadCodigo && <p className={claseError}>{errors.unidadCodigo.message}</p>}
</div>
<div>
<label htmlFor="proyecto" className={claseLabel}>Proyecto *</label>
<select id="proyecto" className={claseInput} {...register('proyecto')}>
{PROYECTOS.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</div>
<div>
<label htmlFor="ventaRef" className={claseLabel}>Referencia de venta</label>
<input id="ventaRef" type="text" className={claseInput} placeholder="VTA-2026-001" {...register('ventaRef')} />
</div>
<div>
<label htmlFor="fechaInicio" className={claseLabel}>Primer vencimiento *</label>
<input id="fechaInicio" type="date" className={claseInput} {...register('fechaInicio')} />
{errors.fechaInicio && <p className={claseError}>{errors.fechaInicio.message}</p>}
</div>
<div>
<label htmlFor="engancheTotal" className={claseLabel}>Enganche total (MXN) *</label>
<input
id="engancheTotal"
type="number"
step="0.01"
className={claseInput}
{...register('engancheTotal', { valueAsNumber: true })}
/>
{errors.engancheTotal && <p className={claseError}>{errors.engancheTotal.message}</p>}
</div>
<div>
<label htmlFor="numPagos" className={claseLabel}>Número de pagos *</label>
<input
id="numPagos"
type="number"
step="1"
className={claseInput}
{...register('numPagos', { valueAsNumber: true })}
/>
{errors.numPagos && <p className={claseError}>{errors.numPagos.message}</p>}
</div>
<div>
<label htmlFor="tasaMoraMensual" className={claseLabel}>Tasa de mora mensual (%)</label>
<input
id="tasaMoraMensual"
type="number"
step="0.1"
min="0"
className={claseInput}
placeholder="0 = sin mora"
{...register('tasaMoraMensual')}
/>
{errors.tasaMoraMensual && <p className={claseError}>{errors.tasaMoraMensual.message}</p>}
</div>
</div>
{/* Vista previa del calendario */}
{vistaPrevia && (
<div className="mt-8 border border-neutral-200 bg-white p-6">
<h2 className="text-xs font-bold uppercase tracking-widest text-neutral-500">
Vista previa — {vistaPrevia.pagos.length} pagos de {formatoMoneda(vistaPrevia.mensualidad)}
</h2>
<div className="mt-4 max-h-48 overflow-y-auto">
<table className="w-full text-left text-xs">
<thead className="text-neutral-500">
<tr className="border-b border-neutral-900">
<th scope="col" className="py-1">#</th>
<th scope="col" className="py-1">Vencimiento</th>
<th scope="col" className="py-1 text-right">Monto</th>
</tr>
</thead>
<tbody>
{vistaPrevia.pagos.map((p) => (
<tr key={p.numPago} className="border-b border-neutral-200">
<td className="py-1">{p.numPago}</td>
<td className="py-1">{formatoFechaCorta(p.fechaVencimiento)}</td>
<td className="py-1 text-right">{formatoMoneda(p.monto)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<div className="mt-8 flex justify-end gap-3">
<button
type="button"
onClick={() => void navigate('/')}
className="border border-neutral-900 bg-transparent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-neutral-900 transition-colors duration-150 hover:bg-neutral-900 hover:text-white"
>
Cancelar
</button>
<button
type="submit"
className="bg-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:opacity-90 disabled:opacity-40"
>
Guardar plan
</button>
</div>
</form>
</div>
)
}

View File

@@ -0,0 +1,324 @@
import { Fragment, useMemo, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import type { Pago, PlanPago } from '../types'
import { agregarNota, cancelarPlan, obtenerPlan, registrarAbonoEnPlan } from '../services/repository'
import {
avancePlan,
diasAtraso,
moraDePago,
pagadoTotal,
round2,
saldoPlan,
statusPago,
statusPlan,
totalMora,
} from '../lib/cobranza'
import { formatoFechaCorta, formatoMoneda, formatoPorcentaje } from '../lib/formatters'
import { descargar } from '../lib/pdf/estadoCuenta'
import { descargarRecibo } from '../lib/pdf/reciboAbono'
import BadgeStatus from '../components/BadgeStatus'
import KpiCard from '../components/KpiCard'
import ModalAbono from '../components/ModalAbono'
export default function PlanDetallePage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [plan, setPlan] = useState<PlanPago | undefined>(() => (id ? obtenerPlan(id) : undefined))
const [pagoAbono, setPagoAbono] = useState<Pago | null>(null)
const [expandidos, setExpandidos] = useState<Set<string>>(new Set())
const [error, setError] = useState<string | null>(null)
const [notaTexto, setNotaTexto] = useState('')
const [errorNota, setErrorNota] = useState<string | null>(null)
const hoy = useMemo(() => new Date(), [])
if (!plan) {
return (
<div className="border border-neutral-200 bg-white p-16 text-center">
<p className="text-neutral-500">Plan no encontrado.</p>
<Link to="/" className="mt-4 inline-block text-accent hover:underline">
Volver al dashboard
</Link>
</div>
)
}
const status = statusPlan(plan)
const saldo = saldoPlan(plan)
const atraso = diasAtraso(plan, hoy)
const tasaMora = plan.tasaMoraMensual ?? 0
const mora = totalMora(plan, hoy)
const toggleExpandido = (pagoId: string) => {
setExpandidos((prev) => {
const next = new Set(prev)
if (next.has(pagoId)) next.delete(pagoId)
else next.add(pagoId)
return next
})
}
const guardarAbono = (datos: { monto: number; fechaPago: string; referencia: string }) => {
if (!pagoAbono) return
try {
const actualizado = registrarAbonoEnPlan(plan.id, datos, pagoAbono.id)
setPlan(actualizado)
setPagoAbono(null)
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Error al registrar el abono')
}
}
const cancelar = () => {
if (!window.confirm('¿Cancelar este plan? El saldo pendiente dejará de cobrarse.')) return
setPlan(cancelarPlan(plan.id))
}
const guardarNota = () => {
try {
setPlan(agregarNota(plan.id, notaTexto))
setNotaTexto('')
setErrorNota(null)
} catch (e) {
setErrorNota(e instanceof Error ? e.message : 'Error al guardar la nota')
}
}
return (
<div className="space-y-16">
<div className="flex flex-wrap items-start justify-between gap-6">
<div>
<Link to="/" className="text-sm text-accent hover:underline">
← Volver al dashboard
</Link>
<h1 className="mt-2 text-3xl font-light text-neutral-900">{plan.clienteNombre}</h1>
<p className="mt-1 text-sm text-neutral-500">
{plan.unidadCodigo} · {plan.proyecto} · Venta {plan.ventaRef || '—'} ·{' '}
<span className="font-bold text-neutral-900">{status}</span>
{atraso > 0 && <span className="text-red-700"> · {atraso} días de atraso</span>}
</p>
<p className="mt-1 text-sm text-neutral-500">
Tel. {plan.clienteTelefono || '—'}
{tasaMora > 0 && <span> · Mora {tasaMora}% mensual</span>}
</p>
</div>
<div className="flex gap-3">
<button
onClick={() => descargar(plan)}
className="border border-neutral-900 bg-transparent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-neutral-900 transition-colors duration-150 hover:bg-neutral-900 hover:text-white"
>
Estado de cuenta PDF
</button>
{status === 'ACTIVO' && saldo > 0 && (
<button
onClick={cancelar}
className="border border-red-300 bg-white px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-red-700 transition-colors duration-150 hover:bg-red-700 hover:text-white"
>
Cancelar plan
</button>
)}
</div>
</div>
{error && (
<div className="border border-red-300 bg-white px-4 py-3 text-sm text-red-700">{error}</div>
)}
{/* KPIs del plan */}
<div className="grid grid-cols-2 gap-6 lg:grid-cols-5">
<KpiCard titulo="Enganche total" valor={formatoMoneda(plan.engancheTotal)} />
<KpiCard titulo="Pagado" valor={formatoMoneda(pagadoTotal(plan))} />
<KpiCard titulo="Saldo" valor={formatoMoneda(saldo)} />
<KpiCard titulo="% avance" valor={formatoPorcentaje(avancePlan(plan))} />
<KpiCard titulo="Mensualidad" valor={formatoMoneda(plan.mensualidad)} detalle={`${plan.numPagos} pagos`} />
{mora > 0 && (
<KpiCard
titulo="Mora acumulada"
valor={<span className="text-red-700">{formatoMoneda(mora)}</span>}
detalle={`Tasa ${tasaMora}% mensual`}
/>
)}
</div>
{/* Tabla de pagos */}
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-neutral-900 text-xs font-bold uppercase tracking-widest text-neutral-500">
<th scope="col" className="py-3 pr-4">#</th>
<th scope="col" className="py-3 pr-4">Vencimiento</th>
<th scope="col" className="py-3 pr-4 text-right">Monto</th>
<th scope="col" className="py-3 pr-4 text-right">Pagado</th>
<th scope="col" className="py-3 pr-4 text-right">Restante</th>
{tasaMora > 0 && <th scope="col" className="py-3 pr-4 text-right">Mora</th>}
<th scope="col" className="py-3 pr-4">Status</th>
<th scope="col" className="py-3 pr-4">Abonos</th>
<th scope="col" className="py-3" />
</tr>
</thead>
<tbody>
{plan.pagos.map((pago) => {
const st = statusPago(pago, hoy)
const restante = round2(pago.monto - pago.montoPagado)
const moraPago = tasaMora > 0 ? moraDePago(pago, tasaMora, hoy) : 0
const expandido = expandidos.has(pago.id)
return (
<Fragment key={pago.id}>
<tr className="border-b border-neutral-200 transition-colors duration-150 hover:bg-neutral-50">
<td className="py-3 pr-4 font-medium">{pago.numPago}</td>
<td className="py-3 pr-4">{formatoFechaCorta(pago.fechaVencimiento)}</td>
<td className="py-3 pr-4 text-right">{formatoMoneda(pago.monto)}</td>
<td className="py-3 pr-4 text-right">{formatoMoneda(pago.montoPagado)}</td>
<td className="py-3 pr-4 text-right font-medium">{formatoMoneda(restante)}</td>
{tasaMora > 0 && (
<td className="py-3 pr-4 text-right">
{moraPago > 0 ? (
<span className="text-red-700">{formatoMoneda(moraPago)}</span>
) : (
<span className="text-neutral-400">—</span>
)}
</td>
)}
<td className="py-3 pr-4">
<BadgeStatus status={st} />
</td>
<td className="py-3 pr-4">
{pago.aplicaciones.length > 0 ? (
<button
onClick={() => toggleExpandido(pago.id)}
className="text-accent hover:underline"
>
{expandido ? 'Ocultar' : `Ver (${pago.aplicaciones.length})`}
</button>
) : (
<span className="text-neutral-400">—</span>
)}
</td>
<td className="py-3 text-right">
{st !== 'PAGADO' && status === 'ACTIVO' && (
<button
onClick={() => setPagoAbono(pago)}
className="bg-accent px-4 py-2 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:opacity-90"
>
Registrar abono
</button>
)}
</td>
</tr>
{expandido && (
<tr className="border-b border-neutral-200 bg-neutral-50">
<td colSpan={tasaMora > 0 ? 9 : 8} className="px-4 py-4">
<table className="w-full text-xs">
<thead>
<tr className="text-left font-bold uppercase tracking-widest text-neutral-500">
<th scope="col" className="py-1">Fecha</th>
<th scope="col" className="py-1 text-right">Monto</th>
<th scope="col" className="py-1">Referencia</th>
<th scope="col" className="py-1 text-right">Recibo</th>
</tr>
</thead>
<tbody>
{pago.aplicaciones.map((a) => (
<tr key={a.id} className="border-t border-neutral-200">
<td className="py-1">{formatoFechaCorta(a.fechaPago)}</td>
<td className="py-1 text-right">{formatoMoneda(a.monto)}</td>
<td className="py-1">{a.referencia}</td>
<td className="py-1 text-right">
<button
onClick={() => descargarRecibo(plan, pago, a)}
aria-label={`Descargar recibo PDF del abono ${a.referencia}`}
className="text-accent hover:underline"
>
Recibo PDF
</button>
</td>
</tr>
))}
</tbody>
</table>
</td>
</tr>
)}
</Fragment>
)
})}
</tbody>
</table>
</div>
{/* Bitácora del plan */}
<section className="border border-neutral-200 bg-white p-8" aria-labelledby="titulo-bitacora">
<h2 id="titulo-bitacora" className="text-xs font-bold uppercase tracking-widest text-neutral-500">
Bitácora
</h2>
{(plan.notas ?? []).length === 0 ? (
<p className="mt-6 text-sm text-neutral-500">Sin notas registradas.</p>
) : (
<ul className="mt-6 space-y-4">
{[...(plan.notas ?? [])]
.sort((a, b) => b.fecha.localeCompare(a.fecha))
.map((nota) => (
<li key={nota.id} className="border-l-2 border-neutral-200 pl-4">
<p className="text-xs text-neutral-500">
{new Date(nota.fecha).toLocaleString('es-MX', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</p>
<p className="mt-1 text-sm text-neutral-900">{nota.texto}</p>
</li>
))}
</ul>
)}
<form
className="mt-8"
onSubmit={(e) => {
e.preventDefault()
guardarNota()
}}
>
<label
htmlFor="nota-texto"
className="block text-xs font-bold uppercase tracking-widest text-neutral-500"
>
Nueva nota
</label>
<textarea
id="nota-texto"
value={notaTexto}
onChange={(e) => setNotaTexto(e.target.value)}
rows={3}
placeholder="Gestión realizada, acuerdos con el cliente…"
className="mt-1 w-full border border-neutral-300 bg-white px-3 py-2.5 text-sm transition-colors duration-150 focus:border-neutral-900 focus:outline-none"
/>
{errorNota && <p className="mt-1 text-xs text-red-700">{errorNota}</p>}
<div className="mt-4 flex justify-end">
<button
type="submit"
disabled={notaTexto.trim().length < 3}
className="bg-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:opacity-90 disabled:opacity-40"
>
Agregar nota
</button>
</div>
</form>
</section>
{status === 'CANCELADO' && (
<button
onClick={() => void navigate('/')}
className="border border-neutral-900 bg-transparent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-neutral-900 transition-colors duration-150 hover:bg-neutral-900 hover:text-white"
>
Volver
</button>
)}
{pagoAbono && (
<ModalAbono pago={pagoAbono} onCancelar={() => setPagoAbono(null)} onGuardar={guardarAbono} />
)}
</div>
)
}

View File

@@ -0,0 +1,11 @@
import type { PlanPago } from '../types'
import { cargarPlanes } from './repository'
/**
* Devuelve los planes de pago registrados.
* TODO: reemplazar por fetch('/api/planes') cuando el API Gateway esté disponible
* TODO: cron de vencidos y envío WhatsApp van en el API Gateway (fase posterior)
*/
export async function getPlanes(): Promise<PlanPago[]> {
return cargarPlanes()
}

View File

@@ -0,0 +1,116 @@
import { describe, expect, it } from 'vitest'
import {
CLAVE_STORAGE,
actualizarPlan,
agregarNota,
cargarPlanes,
cancelarPlan,
crearPlan,
guardarPlanes,
obtenerPlan,
registrarAbonoEnPlan,
} from './repository'
import type { PlanPago } from '../types'
/** Stub sencillo de localStorage para correr en entorno node */
function crearStorageStub(): Pick<Storage, 'getItem' | 'setItem'> {
const mapa = new Map<string, string>()
return {
getItem: (k: string) => mapa.get(k) ?? null,
setItem: (k: string, v: string) => {
mapa.set(k, v)
},
}
}
describe('repository (localStorage)', () => {
it('siembra datos de ejemplo cuando el storage está vacío', () => {
const storage = crearStorageStub()
const planes = cargarPlanes(storage)
expect(planes.length).toBe(4)
expect(storage.getItem(CLAVE_STORAGE)).not.toBeNull()
// segunda carga lee del storage, no vuelve a sembrar
expect(cargarPlanes(storage)).toEqual(planes)
})
it('round-trip: guardarPlanes → cargarPlanes devuelve lo mismo', () => {
const storage = crearStorageStub()
const planes = cargarPlanes(storage)
const modificados = planes.map((p) => ({ ...p, clienteNombre: `${p.clienteNombre} X` }))
guardarPlanes(modificados, storage)
expect(cargarPlanes(storage)).toEqual(modificados)
})
it('crearPlan persiste y obtiene el plan por id', () => {
const storage = crearStorageStub()
cargarPlanes(storage)
const plan = crearPlan(
{
clienteNombre: 'Nuevo Cliente',
unidadCodigo: 'U-999',
proyecto: 'DIAGONAL',
ventaRef: '',
fechaInicio: '2026-09-01',
engancheTotal: 60000,
numPagos: 6,
},
storage,
)
expect(plan.mensualidad).toBe(10000)
expect(plan.pagos).toHaveLength(6)
expect(obtenerPlan(plan.id, storage)).toEqual(plan)
expect(cargarPlanes(storage)).toHaveLength(5)
})
it('registrarAbonoEnPlan aplica el abono y deriva LIQUIDADO al cubrir todo', () => {
const storage = crearStorageStub()
cargarPlanes(storage)
let plan = crearPlan(
{
clienteNombre: 'Cliente Liquida',
unidadCodigo: 'U-500',
proyecto: 'SENDA',
ventaRef: '',
fechaInicio: '2026-09-01',
engancheTotal: 20000,
numPagos: 2,
},
storage,
)
plan = registrarAbonoEnPlan(plan.id, { fechaPago: '2026-09-01', monto: 10000, referencia: 'A1' }, plan.pagos[0].id, storage)
expect(plan.status).toBe('ACTIVO')
plan = registrarAbonoEnPlan(plan.id, { fechaPago: '2026-10-01', monto: 10000, referencia: 'A2' }, plan.pagos[1].id, storage)
expect(plan.status).toBe('LIQUIDADO')
expect(obtenerPlan(plan.id, storage)?.status).toBe('LIQUIDADO')
})
it('actualizarPlan y cancelarPlan persisten los cambios', () => {
const storage = crearStorageStub()
const [primero] = cargarPlanes(storage)
actualizarPlan({ ...primero, ventaRef: 'VTA-EDIT' }, storage)
expect(obtenerPlan(primero.id, storage)?.ventaRef).toBe('VTA-EDIT')
cancelarPlan(primero.id, storage)
expect(obtenerPlan(primero.id, storage)?.status).toBe('CANCELADO')
})
it('migra planes viejos sin notas agregando notas: []', () => {
const storage = crearStorageStub()
const [primero] = cargarPlanes(storage)
// Simula un registro guardado con una versión anterior del modelo
const viejo = { ...primero } as Partial<PlanPago>
delete viejo.notas
storage.setItem(CLAVE_STORAGE, JSON.stringify([viejo]))
const [migrado] = cargarPlanes(storage)
expect(migrado.notas).toEqual([])
})
it('agregarNota persiste la nota en la bitácora del plan', () => {
const storage = crearStorageStub()
const [primero] = cargarPlanes(storage)
const actualizado = agregarNota(primero.id, 'Se habló con el cliente', storage)
expect(actualizado.notas).toHaveLength(1)
expect(actualizado.notas[0].texto).toBe('Se habló con el cliente')
expect(obtenerPlan(primero.id, storage)?.notas).toHaveLength(1)
expect(() => agregarNota(primero.id, 'ok', storage)).toThrow(/al menos 3/)
})
})

View File

@@ -0,0 +1,115 @@
import type { PlanPago } from '../types'
import { generarSeed } from '../data/seed'
import { generarCalendario, registrarAbono, statusPlan, agregarNotaAPlan, type AbonoInput } from '../lib/cobranza'
import { uuid } from '../lib/uuid'
export const CLAVE_STORAGE = 'frasa_cobranza_v1'
// Inyectable para pruebas (stub en vitest, donde no hay localStorage)
type StorageLike = Pick<Storage, 'getItem' | 'setItem'>
function storageDefault(): StorageLike {
return globalThis.localStorage
}
/**
* Carga los planes desde localStorage; si está vacío siembra los datos de ejemplo.
* Migra planes guardados con versiones anteriores del modelo (sin `notas`).
* TODO: reemplazar por GET /api/planes cuando el API Gateway esté disponible.
*/
export function cargarPlanes(storage: StorageLike = storageDefault()): PlanPago[] {
const crudo = storage.getItem(CLAVE_STORAGE)
if (crudo) {
const planes = JSON.parse(crudo) as PlanPago[]
return planes.map((p) => (p.notas ? p : { ...p, notas: [] }))
}
const planes = generarSeed()
guardarPlanes(planes, storage)
return planes
}
export function guardarPlanes(planes: PlanPago[], storage: StorageLike = storageDefault()): void {
storage.setItem(CLAVE_STORAGE, JSON.stringify(planes))
}
export function obtenerPlan(id: string, storage: StorageLike = storageDefault()): PlanPago | undefined {
return cargarPlanes(storage).find((p) => p.id === id)
}
export interface NuevoPlanInput {
clienteNombre: string
clienteTelefono?: string
unidadCodigo: string
proyecto: string
ventaRef: string
fechaInicio: string
engancheTotal: number
numPagos: number
tasaMoraMensual?: number
}
export function crearPlan(input: NuevoPlanInput, storage: StorageLike = storageDefault()): PlanPago {
const { mensualidad, pagos } = generarCalendario(input.fechaInicio, input.engancheTotal, input.numPagos)
const plan: PlanPago = {
id: uuid(),
ventaRef: input.ventaRef,
clienteNombre: input.clienteNombre,
clienteTelefono: input.clienteTelefono || undefined,
unidadCodigo: input.unidadCodigo,
proyecto: input.proyecto,
fechaInicio: input.fechaInicio,
engancheTotal: input.engancheTotal,
numPagos: input.numPagos,
mensualidad,
tasaMoraMensual: input.tasaMoraMensual,
status: 'ACTIVO',
pagos,
notas: [],
createdAt: new Date().toISOString(),
}
guardarPlanes([...cargarPlanes(storage), plan], storage)
return plan
}
export function actualizarPlan(plan: PlanPago, storage: StorageLike = storageDefault()): PlanPago {
// El status operativo (ACTIVO/LIQUIDADO) se deriva del saldo; CANCELADO es manual
const actualizado: PlanPago = { ...plan, status: statusPlan(plan) }
guardarPlanes(
cargarPlanes(storage).map((p) => (p.id === actualizado.id ? actualizado : p)),
storage,
)
return actualizado
}
export function cancelarPlan(id: string, storage: StorageLike = storageDefault()): PlanPago {
const plan = obtenerPlan(id, storage)
if (!plan) throw new Error('Plan no encontrado')
const cancelado: PlanPago = { ...plan, status: 'CANCELADO' }
guardarPlanes(
cargarPlanes(storage).map((p) => (p.id === id ? cancelado : p)),
storage,
)
return cancelado
}
export function registrarAbonoEnPlan(
planId: string,
abono: Omit<AbonoInput, 'pagoId'>,
pagoId: string,
storage: StorageLike = storageDefault(),
): PlanPago {
const plan = obtenerPlan(planId, storage)
if (!plan) throw new Error('Plan no encontrado')
return actualizarPlan(registrarAbono(plan, { ...abono, pagoId }), storage)
}
/** Agrega una nota a la bitácora del plan y persiste el cambio */
export function agregarNota(
planId: string,
texto: string,
storage: StorageLike = storageDefault(),
): PlanPago {
const plan = obtenerPlan(planId, storage)
if (!plan) throw new Error('Plan no encontrado')
return actualizarPlan(agregarNotaAPlan(plan, texto), storage)
}

View File

@@ -0,0 +1,42 @@
export type StatusPlan = 'ACTIVO' | 'LIQUIDADO' | 'CANCELADO'
export type StatusPago = 'PENDIENTE' | 'PAGADO' | 'VENCIDO' | 'PARCIAL'
export interface PlanPago {
id: string // uuid
ventaRef: string // referencia de la venta (en el futuro: mondayItemId)
clienteNombre: string
clienteTelefono?: string // teléfono de contacto (opcional)
unidadCodigo: string // p. ej. 'U-101'
proyecto: string // p. ej. 'SENDA'
fechaInicio: string // ISO YYYY-MM-DD (primer vencimiento)
engancheTotal: number // MXN
numPagos: number
mensualidad: number // MXN (calculada)
tasaMoraMensual?: number // % mensual sobre el restante vencido; 0 o ausente = sin mora
status: StatusPlan
pagos: Pago[]
notas: NotaPlan[] // bitácora del plan (cronológica)
createdAt: string
}
export interface NotaPlan {
id: string
fecha: string // ISO con hora
texto: string
}
export interface Pago {
id: string
numPago: number // 1..N
fechaVencimiento: string // ISO
monto: number // mensualidad programada
montoPagado: number // acumulado aplicado
aplicaciones: AplicacionPago[] // historial de abonos
}
export interface AplicacionPago {
id: string
fechaPago: string // ISO
monto: number
referencia: string // referencia bancaria / folio
}

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"types": ["vite/client"]
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts", "vitest.config.ts"]
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// La app se sirve detrás de NGINX bajo el prefijo /pay
export default defineConfig({
base: '/pay/',
plugins: [react(), tailwindcss()],
})

View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config'
// Config de tests separada para no mezclar los tipos de Vite y Vitest
export default defineConfig({
test: {
environment: 'node',
},
})