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:
326
apps/cobranza/src/lib/cobranza.test.ts
Normal file
326
apps/cobranza/src/lib/cobranza.test.ts
Normal 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/)
|
||||
})
|
||||
})
|
||||
267
apps/cobranza/src/lib/cobranza.ts
Normal file
267
apps/cobranza/src/lib/cobranza.ts
Normal 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 }
|
||||
}
|
||||
36
apps/cobranza/src/lib/formatters.ts
Normal file
36
apps/cobranza/src/lib/formatters.ts
Normal 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}`
|
||||
}
|
||||
49
apps/cobranza/src/lib/pdf/estadoCuenta.test.ts
Normal file
49
apps/cobranza/src/lib/pdf/estadoCuenta.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
112
apps/cobranza/src/lib/pdf/estadoCuenta.ts
Normal file
112
apps/cobranza/src/lib/pdf/estadoCuenta.ts
Normal 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)
|
||||
}
|
||||
50
apps/cobranza/src/lib/pdf/reciboAbono.test.ts
Normal file
50
apps/cobranza/src/lib/pdf/reciboAbono.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
106
apps/cobranza/src/lib/pdf/reciboAbono.ts
Normal file
106
apps/cobranza/src/lib/pdf/reciboAbono.ts
Normal 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)
|
||||
}
|
||||
14
apps/cobranza/src/lib/uuid.ts
Normal file
14
apps/cobranza/src/lib/uuid.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user