Skip to main content

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 EMISION
  • ERROR EN EL RUC DEL CLIENTE
  • ERROR EN EL MONTO
  • OPERACION NO REALIZADA
  • DUPLICIDAD DE COMPROBANTE
  • OTROS
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

1

Identificar Documento a Anular

Verificar que el documento existe y fue aceptado por SUNAT
2

Crear Comunicación de Baja

Agrupar uno o más documentos para anular
3

Enviar a SUNAT

Transmitir vía SOAP, recibir ticket
4

Consultar Ticket

Consultar estado cada 10 segundos hasta respuesta definitiva
5

Actualizar Estado

Si aceptado, marcar documento como anulado en base de datos
6

Generar Nota de Crédito (Opcional)

Si es necesario, emitir nota de crédito para reversas contables

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

  1. Irreversibilidad: Una vez aceptada la baja, NO se puede revertir
  2. Plazo: SUNAT permite bajas hasta 7 días calendario después de la emisión
  3. Notas de Crédito: Para reversas posteriores a 7 días, usar Nota de Crédito
  4. 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 tabla ventas 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(),
]);

Build docs developers (and LLMs) love