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,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)
})
}