Files
FRASA-CAS/apps/cobranza/src/lib/pdf/reciboAbono.ts
Consult AS Technology 8a2584438d fix(auditoria general): 25 errores encontrados en la revision modulo por modulo
SEGURIDAD (agente IA):
- Conversaciones: GET /ai/conversations/:id y el reanudar chat verifican
  propiedad (antes cualquier usuario leia/continuaba la conversacion de otro).
- /ai/documents pasa a solo-admin (antes cualquien podia inyectar o borrar
  documentos de la base de conocimiento).

DATOS / CORRUPCION:
- Cotizador: elegir un cliente del maestro en el buscador ya no crea un lead
  fantasma "Registro previo (CRM anterior)" en el embudo.
- Afluencia con oportunidadId inexistente: 404 en vez de crear sombra fantasma.
- Cobranza: los 103 planes importados quedaron con vendedor asignado (7 con su
  asesor real segun el Excel, el resto al admin) y el motor de recordatorios
  cae al admin cuando un plan no tiene vendedor: antes los 411 recordatorios
  se registraban 'sin-destinatario' y no los veia nadie.
- Backups: script reescrito para el host real (sqlite .backup + uploads + env
  en /root/backups) y cron instalado (antes: script que asumia docker y sin
  cron programado = cero respaldos).

REPORTES / METRICAS:
- Mi tablero: el embudo ya excluye cancelaciones reales (Cancelo/Cancelacion
  Apartado) y cuenta como vendidos Firma Cliente/Escriturado/Titulado (antes
  41 "apartados" donde habia 13 activos y vendidos buscaba una etapa que no
  existe); tasa de descarte ya compara el mismo periodo.
- Scoring: etapa real 'Agendo Cita' (antes 'AGENDO' nunca aplicaba) y los
  descartados/perdidos ya no reciben score.
- ver_cobranza (IA): ignora planes cancelados y no cuenta como vencido el pago
  que vence hoy.

UI / FLUJOS:
- mi-dia: typo CANCO -> CANCELO (los cancelados reaparecen en Escrituracion);
  confirmar cita ya no retrocede la etapa de Cotizador/Aparto (boton y kanban).
- alta-clientes: la firma ya no se borra al navegar fuera del paso de firmas;
  toast de exito solo cuando el API confirma; prefill de apartado trae
  direccion, estado civil y fecha de nacimiento desde el alta.
- Auditoria: el filtro por usuario funciona (el front enviaba 'usuario' y el
  API esperaba 'usuarioId') y 'hasta' incluye todo el dia.
- Contactos: paginacion aplicada despues de fusionar fuentes (antes saltaban o
  se repetian contactos entre paginas y 'Siguiente' se deshabilitaba antes de
  tiempo).
- Buscador global: leads -> /hoy/, contactos -> /; el cotizador entiende
  ?cotizacion=<id> para retomar desde la liga.
- Export mi-dia: columnas Desarrollo/Origen rellenas y sin duplicados.
- Recibo de cobranza: "Recibio" es FRASA (el cliente paga, no recibe).
- Alta de plan de cobranza: proyecto libre con sugerencias del catalogo real
  (antes limitado a 3 proyectos que no cubrian la cartera).

Verificado en vivo: autorizacion IA (3x 403), filtro de auditoria, 404 de
afluencia. Suites: 717 API + 124 alta-clientes + 115 cotizador + 92 cobranza +
89 landing + 32 mi-dia en verde. Backup de hoy generado en /root/backups.
2026-09-24 10:54:09 +00:00

106 lines
3.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { jsPDF } from 'jspdf'
import autoTable from 'jspdf-autotable'
import type { AplicacionPago, Pago, PlanPago } from '../../types'
import { round2, saldoPlan } 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()
}
/**
* Saldos que imprime el recibo, tomados de los TOTALES del plan y del pago
* (suma de montos − suma de pagado), no del historial de `AplicacionPago`:
* el enganche/apartado histórico vive en `Pago.montoPagado` sin fila de
* aplicación, así que acumular solo aplicaciones daba saldos falsos
* (incluso negativos). El saldo nunca puede ser negativo porque un abono
* jamás excede el restante de su pago.
*/
export function saldosDelRecibo(plan: PlanPago, pago: Pago): { restantePago: number; saldoPlan: number } {
return {
restantePago: round2(pago.monto - pago.montoPagado),
saldoPlan: saldoPlan(plan),
}
}
/**
* Construye el recibo de pago de una aplicación (abono) ya registrada.
* El restante del pago y el saldo del plan son los totales vigentes del plan
* (ver `saldosDelRecibo`).
*/
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
const { restantePago, saldoPlan: saldo } = saldosDelRecibo(plan, pago)
// 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(restantePago)],
['SALDO DEL PLAN (a la fecha de emisión)', formatoMoneda(saldo)],
],
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
// Quien recibe el pago es FRASA (el cliente es quien paga)
doc.text('Recibió — FRASA DESARROLLOS S.A.P.I. DE C.V.', 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)
}