fix(facturacion): saldo pendiente PPD + seguridad cancelación multi-contribuyente
- 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
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
<td className="py-3">
|
||||
<div className="flex items-center gap-0">
|
||||
{(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)) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -2096,7 +2099,7 @@ export default function CfdiPage() {
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
))}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -112,8 +112,8 @@ export const updatePaqueteCatalogo = (id: number, data: { precio?: number; activ
|
||||
apiClient.put<PaqueteCatalogoAdmin>(`/facturacion/timbres/paquetes-catalogo/${id}`, data).then(r => r.data);
|
||||
export const emitirFactura = (data: InvoiceData) =>
|
||||
apiClient.post<InvoiceResult>('/facturacion/emitir', data).then(r => r.data);
|
||||
export const cancelarFactura = (uuid: string, motive?: string, substitution?: string) =>
|
||||
apiClient.post(`/facturacion/cancelar/${uuid}`, { motive, substitution }).then(r => r.data);
|
||||
export const cancelarFactura = (uuid: string, motive?: string, substitution?: string, contribuyenteId?: string) =>
|
||||
apiClient.post(`/facturacion/cancelar/${uuid}`, { motive, substitution, contribuyenteId }).then(r => r.data);
|
||||
export const downloadPdf = (id: string) =>
|
||||
apiClient.get(`/facturacion/pdf/${id}`, { responseType: 'blob' }).then(r => r.data);
|
||||
export const downloadXml = (id: string) =>
|
||||
|
||||
Reference in New Issue
Block a user