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ística Anulación Directa Comunicación de Baja Fecha de emisión Hoy Fecha anterior Envío a SUNAT No Sí (obligatorio) Tiempo de proceso Inmediato Hasta 24 horas Retorna stock Sí (automático) Sí (automático) Documento XML No genera Ra-YYYYMMDD-.xml Ticket SUNAT No aplica Sí (para consulta) Reversible No No
Anulación Directa: Flujo Paso a Paso
Acceder a Lista de Ventas
Ruta: /ventasComponente: VentasList.jsxFiltrar por estado “Activo” para ver ventas anulables.
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 );
}
};
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 );
}
}
Verificar Anulación
Efectos de la anulación:
Estado de la venta:
UPDATE ventas
SET estado = '2'
WHERE id_venta = {id};
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' ,
...
);
Registro de anulación:
INSERT INTO ventas_anuladas (
id_venta,
motivo_anulacion,
fecha_anulacion,
total_anulado,
estado_comunicacion_baja = '0'
);
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
Detectar Necesidad de Comunicación de Baja
Escenarios:
Usuario anula venta de fecha anterior (campo estado_comunicacion_baja = '0')
Sistema detecta automáticamente que requiere envío a SUNAT
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 ();
Enviar Comunicación de Baja
Proceso manual desde módulo SUNAT: Ruta: /sunat/comunicacion-baja Endpoint: 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 );
}
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
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.
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 ,
];
}
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ística Anulación Nota de Crédito Propósito Cancelar comprobante completamente Modificar o corregir Efecto Comprobante inválido Comprobante sigue válido, pero ajustado Documento generado Comunicación de Baja (RA) Nota de Crédito (07) Monto Siempre total Puede ser parcial Stock Retorna automáticamente No retorna (manual) Cuándo usar Error grave, cancelación total Devolució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
Error: Solo se puede anular ventas activas
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
Comunicación de Baja rechazada por SUNAT
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
Ticket no devuelve resultado después de 24 horas
Causa: SUNAT aún no procesó la solicitud o hay un problema en su sistema.Solución:
Esperar 48 horas
Consultar manualmente en SUNAT SOL
Verificar logs de errores
Contactar soporte SUNAT si persiste
Stock no retornó al anular
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: