Visión General
La Comunicación de Baja (RA) permite anular documentos sincrónicos que ya fueron aceptados por SUNAT, principalmente facturas y notas de crédito/débito.La Comunicación de Baja es IRREVERSIBLE. Una vez aceptada, el documento queda anulado sin posibilidad de reactivación.
Diferencia con Resumen Diario de Baja
Comunicación de Baja
- Para facturas (01) y notas (07, 08)
- Documentos sincrónicos
- Serie:
RA-YYYYMMDD-### - Envío asíncrono con ticket
Resumen Diario de Baja
- Para boletas (03)
- Usa estado ‘3’ en Resumen Diario
- Serie:
RC-YYYYMMDD-### - Envío asíncrono con ticket
Generación de Comunicación de Baja
Método Principal
SunatService.php:1063
public function comunicacionBaja(
Empresa $empresa,
array $documentos,
string $correlativo = '001'
): array {
$company = $this->buildCompany($empresa);
$details = [];
foreach ($documentos as $doc) {
$details[] = (new VoidedDetail())
->setTipoDoc($doc['tipo_doc'] ?? '01') // '01' = Factura
->setSerie($doc['serie']) // F001
->setCorrelativo($doc['correlativo']) // 00000123
->setDesMotivoBaja($doc['motivo'] ?? 'ERROR EN EMISION');
}
if (empty($details)) {
return ['success' => false, 'message' => 'No hay documentos para dar de baja.'];
}
$voided = (new Voided())
->setCorrelativo($correlativo)
->setFecGeneracion(new \DateTime()) // Hoy
->setFecComunicacion(new \DateTime()) // Hoy
->setCompany($company)
->setDetails($details);
$see = $this->getSee($empresa);
$nombreArchivo = $voided->getName(); // {RUC}-RA-{YYYYMMDD}-{COR}
$result = $see->send($voided);
$ruc = $this->getRuc($empresa);
$xmlContent = $see->getFactory()->getLastXml();
if ($xmlContent) {
$this->guardarXml($empresa, $nombreArchivo, $xmlContent);
}
if ($result->isSuccess()) {
$ticket = $result->getTicket();
return [
'success' => true,
'ticket' => $ticket,
'nombre_archivo' => $nombreArchivo,
'message' => 'Comunicación de baja enviada. Use el ticket para consultar el estado.',
];
}
$error = $result->getError();
Log::error('SUNAT - Comunicación de baja rechazada', [
'codigo' => $error->getCode(),
'mensaje' => $error->getMessage(),
]);
return [
'success' => false,
'codigo' => $error->getCode(),
'message' => $error->getMessage(),
];
}
Estructura de VoidedDetail
Cada documento a anular requiere:[
'tipo_doc' => '01', // '01' = Factura, '07' = NC, '08' = ND
'serie' => 'F001',
'correlativo' => '00000123', // Sin la serie, solo el número
'motivo' => 'ERROR EN EMISION' // Motivo de anulación
]
Motivos de Anulación
Motivos comunes aceptados por SUNAT:ERROR EN EMISIONERROR EN EL RUC DEL CLIENTEERROR EN EL MONTOOPERACION NO REALIZADADUPLICIDAD DE COMPROBANTEOTROS
El motivo es descriptivo y no afecta la validez de la anulación, pero debe ser claro para auditorías.
Consulta de Ticket
La Comunicación de Baja retorna un ticket que debe consultarse:SunatService.php:1232
public function consultarTicket(Empresa $empresa, string $ticket): array
{
$see = $this->getSee($empresa);
$result = $see->getStatus($ticket);
$ruc = $this->getRuc($empresa);
if ($result->isSuccess()) {
$cdr = $result->getCdrResponse();
$cdrZip = $result->getCdrZip();
if ($cdrZip) {
$cdrDir = storage_path("app/sunat/cdr/{$ruc}");
if (!is_dir($cdrDir)) {
mkdir($cdrDir, 0755, true);
}
file_put_contents("{$cdrDir}/R-ticket-{$ticket}.zip", $cdrZip);
}
return [
'success' => true,
'codigo' => $cdr->getCode(),
'mensaje' => $cdr->getDescription(),
'notas' => $cdr->getNotes() ?? [],
];
}
$code = $result->getCode();
if ($code === '98') {
return [
'success' => true,
'codigo' => '98',
'mensaje' => 'En proceso. Intente nuevamente en unos segundos.',
'en_proceso' => true,
];
}
$error = $result->getError();
return [
'success' => false,
'codigo' => $error ? $error->getCode() : $code,
'message' => $error ? $error->getMessage() : 'Error desconocido',
];
}
Flujo de Trabajo
Implementación en Controlador
use App\Services\SunatService;
use App\Models\Venta;
class ComunicacionBajaController extends Controller
{
public function __construct(
private SunatService $sunatService
) {}
public function anularVenta(Request $request, $id)
{
$venta = Venta::findOrFail($id);
$empresa = $venta->empresa;
// Validaciones
if ($venta->estado === '2') {
return response()->json([
'success' => false,
'message' => 'Esta venta ya está anulada.'
]);
}
if ($venta->estado_sunat !== '1') {
return response()->json([
'success' => false,
'message' => 'Solo se pueden anular ventas aceptadas por SUNAT.'
]);
}
// Solo facturas pueden anularse con Comunicación de Baja
$codSunat = $venta->tipoDocumento->cod_sunat;
if ($codSunat === '03') {
return response()->json([
'success' => false,
'message' => 'Las boletas se anulan mediante Resumen Diario de Baja.'
]);
}
// Generar correlativo
$ultimaBaja = \DB::table('comunicaciones_baja')
->where('id_empresa', $empresa->id_empresa)
->whereDate('fecha_comunicacion', today())
->max('correlativo');
$correlativo = str_pad(((int)$ultimaBaja + 1), 3, '0', STR_PAD_LEFT);
// Enviar comunicación de baja
$resultado = $this->sunatService->comunicacionBaja(
$empresa,
[[
'tipo_doc' => $codSunat,
'serie' => $venta->serie,
'correlativo' => (string) $venta->numero,
'motivo' => $request->motivo ?? 'ERROR EN EMISION',
]],
$correlativo
);
if ($resultado['success']) {
// Guardar registro
$bajaId = \DB::table('comunicaciones_baja')->insertGetId([
'id_empresa' => $empresa->id_empresa,
'id_venta' => $venta->id_venta,
'fecha_comunicacion' => today(),
'correlativo' => $correlativo,
'ticket' => $resultado['ticket'],
'nombre_archivo' => $resultado['nombre_archivo'],
'motivo' => $request->motivo ?? 'ERROR EN EMISION',
'estado' => 'enviado',
'created_at' => now(),
]);
// Marcar venta como "en proceso de baja"
$venta->update(['estado_sunat' => '4']); // 4 = En proceso de baja
}
return response()->json($resultado);
}
public function consultarTicket(Request $request, $ticket)
{
$empresa = $request->user()->empresa;
$resultado = $this->sunatService->consultarTicket($empresa, $ticket);
if ($resultado['success'] && $resultado['codigo'] === '0') {
// Baja aceptada
$baja = \DB::table('comunicaciones_baja')
->where('ticket', $ticket)
->first();
if ($baja) {
// Marcar venta como anulada
Venta::where('id_venta', $baja->id_venta)
->update([
'estado' => '2', // Anulada
'estado_sunat' => '2', // Anulada en SUNAT
]);
\DB::table('comunicaciones_baja')
->where('ticket', $ticket)
->update([
'estado' => 'aceptado',
'codigo_sunat' => $resultado['codigo'],
'mensaje_sunat' => $resultado['mensaje'],
'updated_at' => now(),
]);
}
}
return response()->json($resultado);
}
public function anularMultiple(Request $request)
{
$request->validate([
'ventas' => 'required|array|min:1',
'ventas.*.id' => 'required|exists:ventas,id_venta',
'ventas.*.motivo' => 'nullable|string|max:200',
]);
$empresa = $request->user()->empresa;
$documentos = [];
foreach ($request->ventas as $ventaData) {
$venta = Venta::find($ventaData['id']);
if ($venta->estado_sunat === '1') { // Solo aceptadas
$documentos[] = [
'tipo_doc' => $venta->tipoDocumento->cod_sunat,
'serie' => $venta->serie,
'correlativo' => (string) $venta->numero,
'motivo' => $ventaData['motivo'] ?? 'ERROR EN EMISION',
];
}
}
if (empty($documentos)) {
return response()->json([
'success' => false,
'message' => 'No hay documentos válidos para anular.'
]);
}
$ultimaBaja = \DB::table('comunicaciones_baja')
->where('id_empresa', $empresa->id_empresa)
->whereDate('fecha_comunicacion', today())
->max('correlativo');
$correlativo = str_pad(((int)$ultimaBaja + 1), 3, '0', STR_PAD_LEFT);
$resultado = $this->sunatService->comunicacionBaja(
$empresa,
$documentos,
$correlativo
);
return response()->json($resultado);
}
}
Tabla de Control
CREATE TABLE comunicaciones_baja (
id INT PRIMARY KEY AUTO_INCREMENT,
id_empresa INT NOT NULL,
id_venta INT, -- Puede ser NULL si es baja múltiple
fecha_comunicacion DATE NOT NULL,
correlativo VARCHAR(3) NOT NULL,
ticket VARCHAR(100),
nombre_archivo VARCHAR(255),
motivo TEXT,
estado VARCHAR(20), -- enviado, aceptado, rechazado
codigo_sunat VARCHAR(10),
mensaje_sunat TEXT,
created_at TIMESTAMP,
updated_at TIMESTAMP,
INDEX (id_empresa, fecha_comunicacion)
);
Integración con Frontend
import { anularVenta, consultarTicketBaja } from '@/services/sunat';
import Swal from 'sweetalert2';
const handleAnularVenta = async (ventaId) => {
// Confirmación
const confirmResult = await Swal.fire({
title: '¿Anular Documento?',
text: 'Esta acción es IRREVERSIBLE. El documento quedará anulado en SUNAT.',
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#d33',
cancelButtonColor: '#3085d6',
confirmButtonText: 'Sí, anular',
cancelButtonText: 'Cancelar',
input: 'text',
inputLabel: 'Motivo de anulación',
inputPlaceholder: 'ERROR EN EMISION',
inputValidator: (value) => {
if (!value) {
return 'Debe especificar un motivo';
}
},
});
if (!confirmResult.isConfirmed) return;
const motivo = confirmResult.value;
try {
// Enviar comunicación de baja
const bajaResult = await anularVenta(ventaId, { motivo });
if (!bajaResult.success) {
toast.error(`Error: ${bajaResult.message}`);
return;
}
const ticket = bajaResult.ticket;
toast.info(`Comunicación enviada. Ticket: ${ticket}`);
// Polling para consultar estado
const maxIntentos = 24; // 4 minutos
let intentos = 0;
const intervalo = setInterval(async () => {
intentos++;
const estadoResult = await consultarTicketBaja(ticket);
if (estadoResult.codigo === '98') {
console.log(`Intento ${intentos}: En proceso...`);
return;
}
clearInterval(intervalo);
if (estadoResult.success) {
toast.success('Documento anulado exitosamente');
// Recargar lista de ventas
queryClient.invalidateQueries(['ventas']);
} else {
toast.error(`Rechazado: ${estadoResult.message}`);
}
}, 10000);
setTimeout(() => {
if (intentos < maxIntentos) {
clearInterval(intervalo);
toast.warning('Timeout. Consulte el ticket manualmente.');
}
}, 240000);
} catch (error) {
toast.error(`Error: ${error.message}`);
}
};
Consideraciones Importantes
- Irreversibilidad: Una vez aceptada la baja, NO se puede revertir
- Plazo: SUNAT permite bajas hasta 7 días calendario después de la emisión
- Notas de Crédito: Para reversas posteriores a 7 días, usar Nota de Crédito
- Boletas: Las boletas NO se anulan con Comunicación de Baja, usar Resumen Diario estado ‘3’
Si necesita “corregir” un documento en lugar de anularlo, considere emitir una Nota de Crédito o Nota de Débito en su lugar.
Estados de Documento
La tablaventas debe manejar estos estados:
// estado_sunat
'0' = 'Pendiente de envío'
'1' = 'Aceptado por SUNAT'
'2' = 'Anulado'
'3' = 'Rechazado por SUNAT'
'4' = 'En proceso de baja'
Auditoría y Trazabilidad
Registrar todos los eventos:\DB::table('auditoria_documentos')->insert([
'id_venta' => $venta->id_venta,
'accion' => 'comunicacion_baja',
'usuario_id' => auth()->id(),
'motivo' => $motivo,
'ticket' => $ticket,
'created_at' => now(),
]);