Skip to main content

Introducción

Existen dos formas de anular comprobantes electrónicos en Perú, según la normativa SUNAT. Es crucial entender cuándo usar cada método.
La anulación es IRREVERSIBLE. Una vez anulado un comprobante, no se puede reactivar. Asegúrese de que la anulación es necesaria antes de proceder.

Métodos de Anulación

1. Anulación Directa (Misma Fecha)

Cuándo usar:
  • El comprobante fue emitido HOY (misma fecha de anulación)
  • No se ha entregado al cliente
  • Error detectado inmediatamente
Características:
  • NO requiere envío a SUNAT
  • Solo cambia el estado del comprobante a “Anulado”
  • Retorna el stock automáticamente
  • No genera ningún documento adicional
  • Efecto inmediato
Uso común: Error de digitación, cliente canceló la compra antes de salir, documento duplicado por error.

2. Comunicación de Baja (Fechas Anteriores)

Cuándo usar:
  • El comprobante fue emitido en fecha anterior a hoy
  • Ya fue enviado a SUNAT
  • Ya se entregó al cliente
Características:
  • Requiere envío a SUNAT
  • Proceso asíncrono con ticket de consulta
  • Genera documento XML de baja
  • Puede tardar hasta 24 horas en procesarse
  • Se registra en tabla ventas_anuladas
Uso común: Cliente devolvió productos días después, error detectado al revisar cuentas, solicitud formal de anulación.

Comparación de Métodos

CaracterísticaAnulación DirectaComunicación de Baja
Fecha de emisiónHoyFecha anterior
Envío a SUNATNoSí (obligatorio)
Tiempo de procesoInmediatoHasta 24 horas
Retorna stockSí (automático)Sí (automático)
Documento XMLNo generaRa-YYYYMMDD-.xml
Ticket SUNATNo aplicaSí (para consulta)
ReversibleNoNo

Anulación Directa: Flujo Paso a Paso

1

Acceder a Lista de Ventas

Ruta: /ventasComponente: VentasList.jsxFiltrar por estado “Activo” para ver ventas anulables.
2

Click en Botón Anular

Desde la tabla de ventas, columna “Acciones”, click en icono de anulación (prohibición).
// ventasColumns.jsx
{
    accessorKey: 'actions',
    header: 'Acciones',
    cell: ({ row }) => (
        <VentasActionButtons venta={row.original} />
    ),
}
Validación frontend:
// VentasActionButtons.jsx
const handleAnular = async () => {
    const result = await Swal.fire({
        title: '¿Anular venta?',
        text: 'Esta acción no se puede revertir. El stock será retornado.',
        icon: 'warning',
        showCancelButton: true,
        confirmButtonText: 'Sí, anular',
        cancelButtonText: 'Cancelar',
    });
    
    if (!result.isConfirmed) return;
    
    const { value: motivo } = await Swal.fire({
        title: 'Motivo de anulación',
        input: 'textarea',
        inputPlaceholder: 'Ingrese el motivo...',
        inputValidator: (value) => {
            if (!value) return 'Debe ingresar un motivo';
        },
        showCancelButton: true,
    });
    
    if (motivo) {
        anularVenta(venta.id_venta, motivo);
    }
};
3

Backend Procesa Anulación

Endpoint: POST /api/ventas/{id}/anular
// VentasController.php línea 410-483
public function anular(Request $request, int $id): JsonResponse
{
    try {
        $validated = $request->validate([
            'motivo_anulacion' => 'required|string|max:500',
        ]);
        
        $user = $request->user();
        
        return DB::transaction(function () use ($id, $validated, $user) {
            // 1. Cargar venta con productos
            $venta = Venta::with(['productosVentas.producto'])
                ->where('id_empresa', $user->id_empresa)
                ->where('estado', '1')  // Solo activas
                ->findOrFail($id);
            
            // 2. Cambiar estado de la venta
            $venta->update(['estado' => '2']);  // 2 = Anulado
            
            // 3. Retornar stock al almacén correcto
            if ($venta->afecta_stock) {
                foreach ($venta->productosVentas as $detalle) {
                    $producto = $detalle->producto;
                    
                    if ($producto) {
                        $stockAnterior = $producto->cantidad;
                        $producto->increment('cantidad', $detalle->cantidad);
                        $stockNuevo = $stockAnterior + $detalle->cantidad;
                        
                        // Registrar movimiento
                        MovimientoStock::create([
                            'id_producto' => $producto->id_producto,
                            'tipo_movimiento' => 'entrada',
                            'cantidad' => $detalle->cantidad,
                            'stock_anterior' => $stockAnterior,
                            'stock_nuevo' => $stockNuevo,
                            'tipo_documento' => 'anulacion_venta',
                            'id_documento' => $venta->id_venta,
                            'documento_referencia' => $venta->serie . '-' . str_pad($venta->numero, 6, '0', STR_PAD_LEFT),
                            'motivo' => 'Anulación de venta',
                            'id_almacen' => $producto->almacen,
                            'id_empresa' => $user->id_empresa,
                            'id_usuario' => $user->id,
                            'fecha_movimiento' => now(),
                        ]);
                    }
                }
            }
            
            // 4. Registrar en tabla de anulaciones
            DB::table('ventas_anuladas')->insert([
                'id_venta' => $venta->id_venta,
                'id_usuario' => $user->id,
                'motivo_anulacion' => $validated['motivo_anulacion'],
                'fecha_anulacion' => now(),
                'tipo_documento' => $venta->tipoDocumento->cod_sunat ?? '',
                'serie' => $venta->serie,
                'numero' => $venta->numero,
                'total_anulado' => $venta->total,
                'estado_comunicacion_baja' => '0',  // 0=Pendiente, 1=Enviado, 2=Aceptado
                'created_at' => now(),
                'updated_at' => now(),
            ]);
            
            return response()->json([
                'success' => true,
                'message' => 'Venta anulada exitosamente' . 
                            ($venta->afecta_stock ? ' (stock retornado)' : ''),
            ]);
        });
    } catch (\Exception $e) {
        Log::error('Error al anular venta: ' . $e->getMessage());
        return response()->json([
            'success' => false,
            'message' => 'Error al anular la venta',
        ], 500);
    }
}
4

Verificar Anulación

Efectos de la anulación:
  1. Estado de la venta:
    UPDATE ventas 
    SET estado = '2' 
    WHERE id_venta = {id};
    
  2. Stock retornado:
    -- Por cada producto:
    UPDATE productos 
    SET cantidad = cantidad + {cantidad_vendida}
    WHERE id_producto = {id};
    
    -- Registro en movimientos_stock:
    INSERT INTO movimientos_stock (
        tipo_movimiento = 'entrada',
        tipo_documento = 'anulacion_venta',
        ...
    );
    
  3. Registro de anulación:
    INSERT INTO ventas_anuladas (
        id_venta,
        motivo_anulacion,
        fecha_anulacion,
        total_anulado,
        estado_comunicacion_baja = '0'
    );
    
  4. Vista en lista:
    • Badge “Anulado” en rojo
    • No aparece en reportes de ventas activas
    • Visible solo con filtro “Anulados”

Comunicación de Baja: Flujo Paso a Paso

1

Detectar Necesidad de Comunicación de Baja

Escenarios:
  1. Usuario anula venta de fecha anterior (campo estado_comunicacion_baja = '0')
  2. Sistema detecta automáticamente que requiere envío a SUNAT
  3. Venta queda en estado “Anulado - Pendiente de Baja”
Query para listar pendientes:
$ventasPendientesBaja = DB::table('ventas_anuladas')
    ->where('estado_comunicacion_baja', '0')
    ->where('id_empresa', $idEmpresa)
    ->get();
2

Enviar Comunicación de Baja

Proceso manual desde módulo SUNAT:Ruta: /sunat/comunicacion-bajaEndpoint: POST /api/sunat/comunicacion-baja
// SunatController.php
public function enviarComunicacionBaja(Request $request)
{
    $request->validate([
        'fecha_referencia' => 'required|date',
        'ventas_ids' => 'required|array',
    ]);
    
    $empresa = $request->user()->empresa;
    
    // 1. Agrupar ventas por fecha de emisión
    $ventasAnuladas = DB::table('ventas_anuladas as va')
        ->join('ventas as v', 'va.id_venta', '=', 'v.id_venta')
        ->whereIn('va.id', $request->ventas_ids)
        ->where('va.estado_comunicacion_baja', '0')
        ->get();
    
    if ($ventasAnuladas->isEmpty()) {
        return response()->json([
            'success' => false,
            'message' => 'No hay ventas pendientes de baja'
        ], 400);
    }
    
    // 2. Generar XML de Comunicación de Baja
    $resultado = $this->sunatService->generarComunicacionBaja(
        $empresa, 
        $ventasAnuladas,
        $request->fecha_referencia
    );
    
    if (!$resultado['success']) {
        return response()->json($resultado, 500);
    }
    
    // 3. Enviar a SUNAT
    $envio = $this->sunatService->enviarComunicacionBaja($resultado['nombreXml']);
    
    if ($envio['success']) {
        // 4. Actualizar estado con ticket
        DB::table('ventas_anuladas')
            ->whereIn('id', $request->ventas_ids)
            ->update([
                'estado_comunicacion_baja' => '1',  // Enviado
                'ticket_sunat' => $envio['ticket'],
                'fecha_envio_baja' => now(),
            ]);
        
        return response()->json([
            'success' => true,
            'message' => 'Comunicación de Baja enviada. Ticket: ' . $envio['ticket'],
            'ticket' => $envio['ticket'],
        ]);
    }
    
    return response()->json($envio, 500);
}
3

Generar XML de Baja

Servicio: SunatService::generarComunicacionBaja()
// SunatService.php
public function generarComunicacionBaja($empresa, $ventas, $fechaReferencia)
{
    // 1. Crear objeto Voided (Comunicación de Baja)
    $voided = new Voided();
    $voided->setCorrelativo($this->obtenerCorrelativoBaja($empresa));  // 001, 002...
    $voided->setFecGeneracion($this->fechaParaGreenter(now()));
    $voided->setFecComunicacion($this->fechaParaGreenter($fechaReferencia));
    
    // 2. Datos de la empresa
    $address = new Address();
    $address->setDireccion($empresa->direccion);
    $address->setUbigueo($empresa->ubigeo ?? '150101');
    
    $company = new Company();
    $company->setRuc($empresa->ruc);
    $company->setRazonSocial($empresa->razon_social);
    $company->setAddress($address);
    $voided->setCompany($company);
    
    // 3. Agregar documentos a anular
    foreach ($ventas as $venta) {
        $detail = new VoidedDetail();
        $detail->setTipoDoc($venta->tipo_documento);  // '01' o '03'
        $detail->setSerie($venta->serie);
        $detail->setCorrelativo($venta->numero);
        $detail->setDesMotivoBaja($venta->motivo_anulacion);
        $voided->addDetail($detail);
    }
    
    // 4. Generar XML
    $see = $this->getSee($empresa);
    $xml = $see->getXmlSigned($voided);
    
    // 5. Guardar XML
    $nombreXml = $empresa->ruc . '-RA-' . date('Ymd') . '-' . $voided->getCorrelativo();
    $rutaXml = "sunat/xml/{$empresa->ruc}/{$nombreXml}.xml";
    Storage::put($rutaXml, $xml);
    
    return [
        'success' => true,
        'nombreXml' => $nombreXml,
        'rutaXml' => $rutaXml,
    ];
}
Formato del nombre:
  • {RUC}-RA-{YYYYMMDD}-{correlativo}
  • Ejemplo: 20612706702-RA-20250306-001.xml
4

Enviar y Obtener Ticket

Proceso asíncrono:
// SunatService.php
public function enviarComunicacionBaja($nombreXml)
{
    $xmlPath = storage_path("app/sunat/xml/{$nombreXml}.xml");
    $xml = file_get_contents($xmlPath);
    
    $see = $this->getSee($empresa);
    
    // Enviar summary (proceso asíncrono)
    $result = $see->sendSummary('RA', $nombreXml, $xml);
    
    if (!$result->isSuccess()) {
        $error = $result->getError();
        return [
            'success' => false,
            'message' => "Error SUNAT: {$error->getCode()} - {$error->getMessage()}",
        ];
    }
    
    // Obtener ticket para consulta posterior
    $ticket = $result->getTicket();
    
    return [
        'success' => true,
        'ticket' => $ticket,
        'message' => 'Comunicación de Baja enviada. Use el ticket para consultar estado.',
    ];
}
El ticket es un identificador único que SUNAT asigna para consultar el resultado del proceso asíncrono.
5

Consultar Estado con Ticket

Job automático (cada hora):
// app/Jobs/ConsultarTicketsSunat.php
class ConsultarTicketsSunat implements ShouldQueue
{
    public function handle()
    {
        // Obtener anulaciones con ticket pendiente
        $pendientes = DB::table('ventas_anuladas')
            ->where('estado_comunicacion_baja', '1')  // Enviado
            ->whereNotNull('ticket_sunat')
            ->get();
        
        foreach ($pendientes as $anulacion) {
            $resultado = $this->sunatService->consultarTicket(
                $anulacion->ticket_sunat
            );
            
            if ($resultado['success'] && $resultado['estado'] === 'aceptado') {
                // Actualizar a aceptado
                DB::table('ventas_anuladas')
                    ->where('id', $anulacion->id)
                    ->update([
                        'estado_comunicacion_baja' => '2',  // Aceptado
                        'cdr_url' => $resultado['cdr_url'],
                    ]);
            }
        }
    }
}
Consulta manual desde UI:
// Frontend
const consultarTicket = async (ticket) => {
    const res = await fetch(
        baseUrl(`/api/sunat/ticket?ticket=${ticket}`),
        { headers: getAuthHeaders() }
    );
    const data = await res.json();
    
    if (data.success) {
        toast.success(`Estado: ${data.estado}`);
        // Recargar lista
        fetchVentasAnuladas();
    }
};
Backend:
// SunatService.php
public function consultarTicket($ticket)
{
    $see = $this->getSee($empresa);
    
    $result = $see->getStatus($ticket);
    
    if (!$result->isSuccess()) {
        return [
            'success' => false,
            'message' => 'Error al consultar ticket',
        ];
    }
    
    $cdr = $result->getCdrResponse();
    $codigo = $cdr->getCode();
    
    $estado = 'procesando';
    if ($codigo === '0') {
        $estado = 'aceptado';
    } elseif ($codigo >= 2000 && $codigo < 4000) {
        $estado = 'rechazado';
    }
    
    // Guardar CDR si está disponible
    $cdrUrl = null;
    if ($result->getCdrZip()) {
        $cdrUrl = $this->guardarCdr($result->getCdrZip(), $nombreXml);
    }
    
    return [
        'success' => true,
        'estado' => $estado,
        'codigo' => $codigo,
        'descripcion' => $cdr->getDescription(),
        'cdr_url' => $cdrUrl,
    ];
}
6

Confirmar Anulación

Estados finales:
  • estado_comunicacion_baja = '2'Aceptado por SUNAT
    • Anulación válida
    • CDR disponible
    • Proceso completado
  • estado_comunicacion_baja = '3'Rechazado por SUNAT
    • Anulación inválida
    • Revisar motivo de rechazo
    • Puede requerir Nota de Crédito en su lugar
Notificación automática:
// Enviar email al usuario
Mail::to($usuario->email)->send(
    new ComunicacionBajaAceptada($venta, $cdrUrl)
);

Tabla: ventas_anuladas

CREATE TABLE ventas_anuladas (
    id INT PRIMARY KEY AUTO_INCREMENT,
    id_venta INT,
    id_usuario INT,
    motivo_anulacion TEXT,
    fecha_anulacion DATETIME,
    tipo_documento VARCHAR(2),      -- '01', '03'
    serie VARCHAR(4),
    numero INT,
    total_anulado DECIMAL(10,2),
    estado_comunicacion_baja TINYINT DEFAULT 0,
    -- 0 = Pendiente de envío
    -- 1 = Enviado (con ticket)
    -- 2 = Aceptado por SUNAT
    -- 3 = Rechazado por SUNAT
    ticket_sunat VARCHAR(100),
    fecha_envio_baja DATETIME,
    cdr_url VARCHAR(255),
    created_at TIMESTAMP,
    updated_at TIMESTAMP,
    
    FOREIGN KEY (id_venta) REFERENCES ventas(id_venta),
    FOREIGN KEY (id_usuario) REFERENCES users(id)
);

Árbol de Decisión

¿Necesitas anular un comprobante?

├── ¿Fue emitido HOY?
│   │
│   ├── SÍ → Anulación Directa
│   │       • Click "Anular" en lista de ventas
│   │       • Ingresar motivo
│   │       • Confirmar
│   │       • Stock retorna automáticamente
│   │       • NO requiere SUNAT
│   │
│   └── NO → ¿Ya fue enviado a SUNAT?
│           │
│           ├── SÍ → Comunicación de Baja
│           │       • Anular en sistema (genera registro)
│           │       • Ir a SUNAT → Comunicación de Baja
│           │       • Seleccionar ventas pendientes
│           │       • Enviar a SUNAT
│           │       • Obtener ticket
│           │       • Consultar estado (hasta 24h)
│           │       • Confirmar aceptación
│           │
│           └── NO → Anulación Directa
│                   (no enviado = misma fecha efectiva)

└── ¿Solo necesitas modificar monto/items?

    └── SÍ → Emitir Nota de Crédito
            (Ver guía: Emitir Nota de Crédito)

Diferencias con Nota de Crédito

CaracterísticaAnulaciónNota de Crédito
PropósitoCancelar comprobante completamenteModificar o corregir
EfectoComprobante inválidoComprobante sigue válido, pero ajustado
Documento generadoComunicación de Baja (RA)Nota de Crédito (07)
MontoSiempre totalPuede ser parcial
StockRetorna automáticamenteNo retorna (manual)
Cuándo usarError grave, cancelación totalDevolución, descuento, corrección
NO confundir: Anular elimina el comprobante del sistema SUNAT. Nota de Crédito genera un nuevo documento que ajusta el original.

Errores Comunes

Causa: Intentando anular una venta ya anulada (estado = '2').Solución:
  • Verificar estado en lista de ventas
  • Si aparece como “Anulado”, ya fue procesado
  • No se puede anular dos veces
Códigos comunes:
  • 2324: Documento no existe en SUNAT
    • Solución: Verificar que la venta original fue enviada y aceptada
  • 2325: Fecha de baja inválida
    • Solución: La fecha de referencia debe ser igual a la fecha de emisión del comprobante
  • 2326: Documento ya fue dado de baja
    • Solución: Ya se envió una comunicación de baja anterior para este documento
Causa: SUNAT aún no procesó la solicitud o hay un problema en su sistema.Solución:
  1. Esperar 48 horas
  2. Consultar manualmente en SUNAT SOL
  3. Verificar logs de errores
  4. Contactar soporte SUNAT si persiste
Verificar:
SELECT * FROM movimientos_stock 
WHERE tipo_documento = 'anulacion_venta' 
AND id_documento = {venta_id};
Si no hay registros:
  • La venta tenía afecta_stock = false
  • O era Nota de Venta (no afecta stock real)
  • Crear ajuste manual en módulo Almacén

Reportes de Anulaciones

Listar ventas anuladas

Query:
$ventasAnuladas = DB::table('ventas_anuladas as va')
    ->join('ventas as v', 'va.id_venta', '=', 'v.id_venta')
    ->join('users as u', 'va.id_usuario', '=', 'u.id')
    ->where('v.id_empresa', $idEmpresa)
    ->select([
        'va.id',
        'va.fecha_anulacion',
        'va.serie',
        'va.numero',
        'va.total_anulado',
        'va.motivo_anulacion',
        'va.estado_comunicacion_baja',
        'u.name as usuario_nombre',
    ])
    ->orderBy('va.fecha_anulacion', 'desc')
    ->get();

Exportar a Excel

Endpoint: GET /api/ventas/anuladas/export/excel Columnas:
  • Fecha de anulación
  • Documento (serie-número)
  • Cliente
  • Monto anulado
  • Motivo
  • Estado comunicación de baja
  • Usuario que anuló

Referencias Técnicas

Controlador principal: app/Http/Controllers/VentasController.php Controlador SUNAT: app/Http/Controllers/SunatController.php Servicio: app/Services/SunatService.php Modelos:
  • app/Models/Venta.php
  • Tabla: ventas_anuladas (sin modelo, usa Query Builder)
Jobs:
  • app/Jobs/ConsultarTicketsSunat.php
Frontend:
  • resources/js/components/Facturacion/Ventas/VentasActionButtons.jsx
  • resources/js/components/SUNAT/ComunicacionBaja.jsx
Rutas API:
POST   /api/ventas/{id}/anular                    // Anular venta
GET    /api/ventas/anuladas                       // Listar anuladas
POST   /api/sunat/comunicacion-baja               // Enviar comunicación
GET    /api/sunat/ticket                // Consultar estado
GET    /api/ventas/anuladas/export/excel          // Exportar reporte
Documentación SUNAT:

Build docs developers (and LLMs) love