Compare commits
2 Commits
98e982c260
...
ba6004ebd6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba6004ebd6 | ||
|
|
b5e307e142 |
@@ -15,6 +15,7 @@ import { prisma } from '../config/database.js';
|
|||||||
import { AppError } from '../middlewares/error.middleware.js';
|
import { AppError } from '../middlewares/error.middleware.js';
|
||||||
import { hasPlatformRole } from '../utils/platform-admin.js';
|
import { hasPlatformRole } from '../utils/platform-admin.js';
|
||||||
import { auditFromReq } from '../utils/audit.js';
|
import { auditFromReq } from '../utils/audit.js';
|
||||||
|
import { recomputarSaldoPendiente } from '../utils/saldo.js';
|
||||||
|
|
||||||
function effectiveTenantId(req: Request): string {
|
function effectiveTenantId(req: Request): string {
|
||||||
return req.viewingTenantId || req.user!.tenantId;
|
return req.viewingTenantId || req.user!.tenantId;
|
||||||
@@ -272,6 +273,11 @@ export async function emitir(req: Request, res: Response, next: NextFunction) {
|
|||||||
contribuyenteId ?? null, xmlString,
|
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
|
// Enviar por email si el receptor tiene email — ruteado a la org correcta
|
||||||
const customerEmail = req.body.customer?.email;
|
const customerEmail = req.body.customer?.email;
|
||||||
if (customerEmail) {
|
if (customerEmail) {
|
||||||
@@ -325,7 +331,7 @@ export async function cancelar(req: Request, res: Response, next: NextFunction)
|
|||||||
try {
|
try {
|
||||||
const tenantId = effectiveTenantId(req);
|
const tenantId = effectiveTenantId(req);
|
||||||
const { uuid } = req.params;
|
const { uuid } = req.params;
|
||||||
const { motive, substitution } = req.body;
|
const { motive, substitution, contribuyenteId: bodyContribuyenteId } = req.body;
|
||||||
|
|
||||||
const pool = req.tenantPool!;
|
const pool = req.tenantPool!;
|
||||||
const { rows } = await pool.query(
|
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 facturapiId = rows[0].facturapi_id;
|
||||||
const cfdiContribuyenteId = rows[0].contribuyente_id as string | null;
|
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
|
const result = cfdiContribuyenteId
|
||||||
? await cancelInvoiceContribuyente(pool, cfdiContribuyenteId, facturapiId, motive || '02', substitution)
|
? await cancelInvoiceContribuyente(pool, cfdiContribuyenteId, facturapiId, motive || '02', substitution)
|
||||||
: await facturapiService.cancelInvoice(tenantId, facturapiId, motive || '02', substitution);
|
: await facturapiService.cancelInvoice(tenantId, facturapiId, motive || '02', substitution);
|
||||||
|
|||||||
@@ -909,7 +909,7 @@ export default function CfdiPage() {
|
|||||||
}
|
}
|
||||||
setCancelling(true);
|
setCancelling(true);
|
||||||
try {
|
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'] });
|
await queryClient.invalidateQueries({ queryKey: ['cfdis'] });
|
||||||
setCancelTarget(null);
|
setCancelTarget(null);
|
||||||
alert('Factura cancelada. El estatus final depende del SAT (puede quedar en "pendiente" si requiere aceptación del receptor).');
|
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">
|
<td className="py-3">
|
||||||
<div className="flex items-center gap-0">
|
<div className="flex items-center gap-0">
|
||||||
{(cfdi as any).source === 'facturapi' && (cfdi.status === 'Vigente' || cfdi.status === '1') && (
|
{(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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -2096,7 +2099,7 @@ export default function CfdiPage() {
|
|||||||
>
|
>
|
||||||
<XCircle className="h-4 w-4" />
|
<XCircle className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
))}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
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);
|
apiClient.put<PaqueteCatalogoAdmin>(`/facturacion/timbres/paquetes-catalogo/${id}`, data).then(r => r.data);
|
||||||
export const emitirFactura = (data: InvoiceData) =>
|
export const emitirFactura = (data: InvoiceData) =>
|
||||||
apiClient.post<InvoiceResult>('/facturacion/emitir', data).then(r => r.data);
|
apiClient.post<InvoiceResult>('/facturacion/emitir', data).then(r => r.data);
|
||||||
export const cancelarFactura = (uuid: string, motive?: string, substitution?: string) =>
|
export const cancelarFactura = (uuid: string, motive?: string, substitution?: string, contribuyenteId?: string) =>
|
||||||
apiClient.post(`/facturacion/cancelar/${uuid}`, { motive, substitution }).then(r => r.data);
|
apiClient.post(`/facturacion/cancelar/${uuid}`, { motive, substitution, contribuyenteId }).then(r => r.data);
|
||||||
export const downloadPdf = (id: string) =>
|
export const downloadPdf = (id: string) =>
|
||||||
apiClient.get(`/facturacion/pdf/${id}`, { responseType: 'blob' }).then(r => r.data);
|
apiClient.get(`/facturacion/pdf/${id}`, { responseType: 'blob' }).then(r => r.data);
|
||||||
export const downloadXml = (id: string) =>
|
export const downloadXml = (id: string) =>
|
||||||
|
|||||||
@@ -429,3 +429,61 @@ Se agregaron 5 campos adicionales al visualizador de CFDI (`CfdiInvoice`) para m
|
|||||||
- **`regimenFiscalLabels`**: 20 regímenes fiscales (601–626)
|
- **`regimenFiscalLabels`**: 20 regímenes fiscales (601–626)
|
||||||
- **`tipoRelacionLabels`**: 7 tipos de relación SAT (`01` Nota de crédito … `07` Aplicación de anticipo)
|
- **`tipoRelacionLabels`**: 7 tipos de relación SAT (`01` Nota de crédito … `07` Aplicación de anticipo)
|
||||||
- **`usoCfdiLabels`**: ya existía, se reutiliza para el receptor
|
- **`usoCfdiLabels`**: ya existía, se reutiliza para el receptor
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Fix: Facturas Facturapi no aparecen en complemento de pago
|
||||||
|
|
||||||
|
**Fecha:** 2026-05-20
|
||||||
|
|
||||||
|
**Problema:** Las facturas emitidas por Facturapi con método de pago PPD no aparecían en el dropdown de "complemento de pago" (tipo P). Solo aparecían las descargadas del SAT.
|
||||||
|
|
||||||
|
**Causa raíz:** Al emitir vía Facturapi, el campo `saldo_pendiente_mxn` quedaba `NULL`. El endpoint `GET /facturacion/cfdis-ppd` filtra con `COALESCE(saldo_pendiente_mxn, 0) > 0`, excluyendo las facturas de Facturapi.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- Después del `INSERT` en `emitir()`, se llama `recomputarSaldoPendiente(pool, [uuid])` para facturas tipo I + método PPD.
|
||||||
|
- Backfill: se recalcularon 352 filas en la BD del tenant `horux_hts240708lja`.
|
||||||
|
|
||||||
|
**Archivos:**
|
||||||
|
- `apps/api/src/controllers/facturacion.controller.ts` — Agregado `recomputarSaldoPendiente` post-emisión
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Seguridad: cancelación de facturas cruzada entre contribuyentes
|
||||||
|
|
||||||
|
**Fecha:** 2026-05-20
|
||||||
|
|
||||||
|
**Problema:** Un usuario viendo como contribuyente **Horux 360** podía cancelar facturas emitidas por **Consultoria Alcaraz Salazar**.
|
||||||
|
|
||||||
|
**Causa raíz:** El endpoint `POST /facturacion/cancelar/:uuid` no validaba ownership del contribuyente. Solo buscaba por UUID y cancelaba.
|
||||||
|
|
||||||
|
**Fix (backend):**
|
||||||
|
- El endpoint ahora recibe `contribuyenteId` del body.
|
||||||
|
- Si el caller envía un `contribuyenteId` y el CFDI pertenece a otro contribuyente → **403 Forbidden**.
|
||||||
|
|
||||||
|
**Fix (frontend):**
|
||||||
|
- `cancelarFactura` ahora pasa `selectedContribuyenteId` al backend.
|
||||||
|
- El botón de cancelar en la tabla de CFDIs solo se muestra si:
|
||||||
|
- Modo legacy: la factura no tiene `contribuyenteId`
|
||||||
|
- Modo multi-RFC: `cfdi.contribuyenteId === selectedContribuyenteId`
|
||||||
|
|
||||||
|
**Archivos:**
|
||||||
|
- `apps/api/src/controllers/facturacion.controller.ts` — Validación 403 + recepción de contribuyenteId
|
||||||
|
- `apps/web/lib/api/facturacion.ts` — `cancelarFactura` acepta `contribuyenteId`
|
||||||
|
- `apps/web/app/(dashboard)/cfdi/page.tsx` — Condicional de visibilidad del botón cancelar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Sync inicial SAT — Consultoria Alcaraz Salazar
|
||||||
|
|
||||||
|
**Fecha:** 2026-05-20
|
||||||
|
|
||||||
|
**Contexto:** La FIEL de Alcaraz Salazar se subió el 2026-05-19, pero la extracción de CSF falló por timeout del SAT. La sincronización inicial nunca se ejecutó (no había job `initial` en `sat_sync_jobs`).
|
||||||
|
|
||||||
|
**Acciones:**
|
||||||
|
- Creado job `initial` manualmente para contribuyente `bd9ba71c-55f9-40d5-a0d7-18909419298b`.
|
||||||
|
- El sync descubrió ~616 CFDIs en bloques 2024–2026.
|
||||||
|
- La tabla `rfcs` se pobló, habilitando el autocompletado del receptor en facturación.
|
||||||
|
|
||||||
|
**Estado:** ✅ Sync completado exitosamente
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user