From b5e307e142cc3dfb28e4f60a5964f1e9d503b7ce Mon Sep 17 00:00:00 2001 From: Horux Dev Date: Wed, 20 May 2026 05:18:34 +0000 Subject: [PATCH] =?UTF-8?q?fix(facturacion):=20saldo=20pendiente=20PPD=20+?= =?UTF-8?q?=20seguridad=20cancelaci=C3=B3n=20multi-contribuyente?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Inicializar saldo_pendiente_mxn al emitir facturas I/PPD vía Facturapi (antes quedaba NULL y no aparecían en complemento de pago) - Validar ownership en cancelación: backend rechaza 403 si el caller intenta cancelar una factura de otro contribuyente - Frontend: ocultar botón cancelar si no se es el emisor de la factura - Frontend: enviar contribuyenteId en la petición de cancelación --- apps/api/src/controllers/facturacion.controller.ts | 14 +++++++++++++- apps/web/app/(dashboard)/cfdi/page.tsx | 7 +++++-- apps/web/lib/api/facturacion.ts | 4 ++-- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/apps/api/src/controllers/facturacion.controller.ts b/apps/api/src/controllers/facturacion.controller.ts index f866728..2f19dff 100644 --- a/apps/api/src/controllers/facturacion.controller.ts +++ b/apps/api/src/controllers/facturacion.controller.ts @@ -15,6 +15,7 @@ import { prisma } from '../config/database.js'; import { AppError } from '../middlewares/error.middleware.js'; import { hasPlatformRole } from '../utils/platform-admin.js'; import { auditFromReq } from '../utils/audit.js'; +import { recomputarSaldoPendiente } from '../utils/saldo.js'; function effectiveTenantId(req: Request): string { return req.viewingTenantId || req.user!.tenantId; @@ -272,6 +273,11 @@ export async function emitir(req: Request, res: Response, next: NextFunction) { contribuyenteId ?? null, xmlString, ]); + // Inicializar saldo pendiente para I/PPD (igual que el flujo SAT) + if (parsed.tipoComprobante === 'I' && parsed.metodoPago === 'PPD' && parsed.uuid) { + await recomputarSaldoPendiente(pool, [parsed.uuid]); + } + // Enviar por email si el receptor tiene email — ruteado a la org correcta const customerEmail = req.body.customer?.email; if (customerEmail) { @@ -325,7 +331,7 @@ export async function cancelar(req: Request, res: Response, next: NextFunction) try { const tenantId = effectiveTenantId(req); const { uuid } = req.params; - const { motive, substitution } = req.body; + const { motive, substitution, contribuyenteId: bodyContribuyenteId } = req.body; const pool = req.tenantPool!; const { rows } = await pool.query( @@ -340,6 +346,12 @@ export async function cancelar(req: Request, res: Response, next: NextFunction) const facturapiId = rows[0].facturapi_id; const cfdiContribuyenteId = rows[0].contribuyente_id as string | null; + // En modelo multi-contribuyente: si el caller envía un contribuyenteId, + // solo puede cancelar facturas de ESE contribuyente. + if (bodyContribuyenteId && cfdiContribuyenteId && bodyContribuyenteId !== cfdiContribuyenteId) { + return res.status(403).json({ message: 'No tienes permiso para cancelar esta factura' }); + } + const result = cfdiContribuyenteId ? await cancelInvoiceContribuyente(pool, cfdiContribuyenteId, facturapiId, motive || '02', substitution) : await facturapiService.cancelInvoice(tenantId, facturapiId, motive || '02', substitution); diff --git a/apps/web/app/(dashboard)/cfdi/page.tsx b/apps/web/app/(dashboard)/cfdi/page.tsx index 620fc92..14e839b 100644 --- a/apps/web/app/(dashboard)/cfdi/page.tsx +++ b/apps/web/app/(dashboard)/cfdi/page.tsx @@ -909,7 +909,7 @@ export default function CfdiPage() { } setCancelling(true); try { - await cancelarFactura(cancelTarget.uuid, cancelMotive, cancelMotive === '01' ? cancelSubstitution.trim() : undefined); + await cancelarFactura(cancelTarget.uuid, cancelMotive, cancelMotive === '01' ? cancelSubstitution.trim() : undefined, selectedContribuyenteId || undefined); await queryClient.invalidateQueries({ queryKey: ['cfdis'] }); setCancelTarget(null); alert('Factura cancelada. El estatus final depende del SAT (puede quedar en "pendiente" si requiere aceptación del receptor).'); @@ -2087,6 +2087,9 @@ export default function CfdiPage() {
{(cfdi as any).source === 'facturapi' && (cfdi.status === 'Vigente' || cfdi.status === '1') && ( + // Solo el contribuyente emisor puede cancelar su propia factura + ((!selectedContribuyenteId && !cfdi.contribuyenteId) || + (selectedContribuyenteId && cfdi.contribuyenteId === selectedContribuyenteId)) && ( - )} + ))}