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:
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)
|
||||
}
|
||||
Reference in New Issue
Block a user