Skip to main content

Visión General

El Resumen Diario (RC) es el mecanismo obligatorio para enviar boletas a SUNAT. Agrupa todas las boletas emitidas en un día y las envía en un solo documento.
Las boletas NO se pueden enviar individualmente. DEBEN enviarse mediante Resumen Diario al final del día.

¿Por Qué Resumen Diario?

SUNAT estableció el Resumen Diario para:
  1. Reducir tráfico: Enviar cientos de boletas juntas en lugar de una por una
  2. Simplificar: Un solo envío para todo el día
  3. Eficiencia: Procesamiento masivo en servidores SUNAT
Las facturas (01) se envían de forma individual e inmediata, mientras que las boletas (03) se agrupan en el Resumen Diario.

Estructura del Resumen Diario

Método de Generación

SunatService.php:1125
public function resumenDiario(
    Empresa $empresa, 
    array $ventas, 
    string $fechaResumen, 
    string $correlativo = '001', 
    string $estado = '1'
): array {
    $company = $this->buildCompany($empresa);
    $igvRate = (float) ($empresa->igv ?? config('sunat.igv'));

    $details = [];
    foreach ($ventas as $venta) {
        // Construir detalle de cada boleta
    }

    $summary = (new Summary())
        ->setFecGeneracion(new \DateTime())                              // Hoy
        ->setFecResumen(\DateTime::createFromFormat('Y-m-d', $fechaResumen))  // Día de las boletas
        ->setCorrelativo($correlativo)                                   // 001, 002, ...
        ->setCompany($company)
        ->setDetails($details);

    $see = $this->getSee($empresa);
    $nombreArchivo = $summary->getName();  // {RUC}-RC-{YYYYMMDD}-{COR}

    $result = $see->send($summary);
    // ... procesar resultado ...
}

Construcción de Detalles

SunatService.php:1129
foreach ($ventas as $venta) {
    $total = (float) $venta->total;
    $apliIgv = (float) $venta->igv > 0;

    // Cálculo de montos
    $montoGravada = $apliIgv ? round($total / ($igvRate + 1), 2) : 0;
    $igvMonto = $apliIgv ? round($total / ($igvRate + 1) * $igvRate, 2) : 0;
    $montoExonerada = $apliIgv ? 0 : $total;

    // Tipo de documento del cliente
    $cliente = $venta->cliente;
    $tipoDocCliente = '0';
    $numDocCliente = '00000000';
    $documento = $cliente->documento ?? '';

    if (!empty($cliente->tipo_doc) && strlen($documento) > 0) {
        $tipoDocCliente = $cliente->tipo_doc;
        $numDocCliente = $documento;
    } elseif (strlen($documento) === 11) {
        $tipoDocCliente = '6'; // RUC
        $numDocCliente = $documento;
    } elseif (strlen($documento) === 8) {
        $tipoDocCliente = '1'; // DNI
        $numDocCliente = $documento;
    }

    $codSunat = $venta->tipoDocumento->cod_sunat ?? '03';

    $detail = (new SummaryDetail())
        ->setTipoDoc($codSunat)                              // '03' para boletas
        ->setSerieNro($venta->serie . '-' . $venta->numero)  // B001-00000123
        ->setEstado($estado)                                 // '1' = Adición
        ->setClienteTipo($tipoDocCliente)
        ->setClienteNro($numDocCliente)
        ->setTotal($total)
        ->setMtoOperGravadas($montoGravada)
        ->setMtoOperExoneradas($montoExonerada)
        ->setMtoOperInafectas(0)
        ->setMtoOtrosCargos(0)
        ->setMtoIGV($igvMonto);

    $details[] = $detail;
}

Estados de Resumen

Adición (1)

Agregar boletas nuevas al resumen

Modificación (2)

Modificar boletas previamente enviadas

Anulación (3)

Anular boletas previamente enviadas

Envío del Resumen

Proceso de Envío

SunatService.php:1186
$see = $this->getSee($empresa);
$nombreArchivo = $summary->getName();  // Ej: 20612706702-RC-20260306-001

$result = $see->send($summary);

$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,
        'cantidad' => count($details),
        'message' => 'Resumen diario enviado. Use el ticket para consultar el estado.',
    ];
}

$error = $result->getError();
Log::error('SUNAT - Resumen diario rechazado', [
    'codigo' => $error->getCode(),
    'mensaje' => $error->getMessage(),
]);

return [
    'success' => false,
    'codigo' => $error->getCode(),
    'message' => $error->getMessage(),
];
El Resumen Diario retorna un ticket, no un CDR inmediato. Debe consultarse el estado posteriormente.

Consulta de Ticket

Después de enviar el resumen, debe consultarse el ticket:
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() ?? [],  // Observaciones de SUNAT
        ];
    }

    $code = $result->getCode();
    if ($code === '98') {
        // Aún en proceso
        return [
            'success' => true,
            'codigo' => '98',
            'mensaje' => 'En proceso. Intente nuevamente en unos segundos.',
            'en_proceso' => true,
        ];
    }

    // Error
    $error = $result->getError();
    Log::error('SUNAT - Consulta ticket baja/resumen rechazada', [
        'ticket' => $ticket,
        'codigo' => $error ? $error->getCode() : $code,
        'mensaje' => $error ? $error->getMessage() : 'Error desconocido',
    ]);
    
    return [
        'success' => false,
        'codigo' => $error ? $error->getCode() : $code,
        'message' => $error ? $error->getMessage() : 'Error desconocido',
    ];
}

Códigos de Estado

CódigoEstadoAcción
0AceptadoResumen procesado exitosamente
98En procesoConsultar nuevamente en 5-10 segundos
99Procesado con observacionesRevisar notas del CDR
4XXXError de validaciónCorregir y reenviar

Resumen Diario de Baja

Para anular boletas previamente enviadas:
SunatService.php:1224
public function resumenDiarioBaja(
    Empresa $empresa, 
    array $ventas, 
    string $fechaResumen, 
    string $correlativo = '001'
): array {
    // Usa estado '3' (Anulación)
    return $this->resumenDiario($empresa, $ventas, $fechaResumen, $correlativo, '3');
}
Solo se pueden anular boletas que ya fueron enviadas y aceptadas en un Resumen Diario previo.

Flujo de Trabajo

1

Emitir Boletas Durante el Día

Generar boletas normalmente, sin envío inmediato a SUNAT
2

Al Final del Día: Agrupar Boletas

Obtener todas las boletas del día que aún no han sido enviadas
3

Generar Resumen Diario

Crear documento Summary con todas las boletas
4

Enviar a SUNAT

Transmitir vía SOAP, recibir ticket
5

Consultar Ticket (Polling)

Consultar cada 10 segundos hasta obtener CDR o error
6

Actualizar Estado de Boletas

Si aceptado, marcar todas las boletas como enviadas

Implementación en Controlador

use App\Services\SunatService;
use App\Models\Venta;

class ResumenDiarioController extends Controller
{
    public function __construct(
        private SunatService $sunatService
    ) {}

    public function enviarResumen(Request $request)
    {
        $fecha = $request->fecha ?? today()->toDateString();
        $empresaId = $request->user()->id_empresa;

        // Obtener boletas del día no enviadas
        $boletas = Venta::where('id_empresa', $empresaId)
            ->whereHas('tipoDocumento', fn($q) => $q->where('cod_sunat', '03'))
            ->whereDate('fecha_emision', $fecha)
            ->where(function($q) {
                $q->whereNull('estado_sunat')
                  ->orWhere('estado_sunat', '0');  // Pendiente
            })
            ->with(['cliente', 'tipoDocumento'])
            ->get();

        if ($boletas->isEmpty()) {
            return response()->json([
                'success' => false,
                'message' => 'No hay boletas para enviar en esta fecha.'
            ]);
        }

        $empresa = auth()->user()->empresa;
        
        // Generar correlativo (001, 002, ...)
        $ultimoResumen = \DB::table('resumenes_diarios')
            ->where('id_empresa', $empresaId)
            ->whereDate('fecha_resumen', $fecha)
            ->max('correlativo');
        $correlativo = str_pad(((int)$ultimoResumen + 1), 3, '0', STR_PAD_LEFT);

        // Enviar resumen
        $resultado = $this->sunatService->resumenDiario(
            $empresa,
            $boletas->all(),
            $fecha,
            $correlativo
        );

        if ($resultado['success']) {
            // Guardar registro de resumen
            \DB::table('resumenes_diarios')->insert([
                'id_empresa' => $empresaId,
                'fecha_resumen' => $fecha,
                'correlativo' => $correlativo,
                'ticket' => $resultado['ticket'],
                'nombre_archivo' => $resultado['nombre_archivo'],
                'cantidad_boletas' => $resultado['cantidad'],
                'estado' => 'enviado',
                'created_at' => now(),
            ]);
        }

        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') {
            // Actualizar boletas como enviadas
            $resumen = \DB::table('resumenes_diarios')
                ->where('ticket', $ticket)
                ->first();

            if ($resumen) {
                Venta::where('id_empresa', $resumen->id_empresa)
                    ->whereDate('fecha_emision', $resumen->fecha_resumen)
                    ->whereHas('tipoDocumento', fn($q) => $q->where('cod_sunat', '03'))
                    ->update(['estado_sunat' => '1']);  // Aceptado

                \DB::table('resumenes_diarios')
                    ->where('ticket', $ticket)
                    ->update([
                        'estado' => 'aceptado',
                        'codigo_sunat' => $resultado['codigo'],
                        'mensaje_sunat' => $resultado['mensaje'],
                        'updated_at' => now(),
                    ]);
            }
        }

        return response()->json($resultado);
    }
}

Integración con Frontend

import { enviarResumenDiario, consultarTicketResumen } from '@/services/sunat';

const procesarResumenDiario = async (fecha) => {
  // 1. Enviar resumen
  const envioResult = await enviarResumenDiario({ fecha });
  
  if (!envioResult.success) {
    toast.error(`Error: ${envioResult.message}`);
    return;
  }

  const ticket = envioResult.ticket;
  toast.info(
    `Resumen enviado. ${envioResult.cantidad} boletas. Ticket: ${ticket}`
  );

  // 2. Polling para consultar estado
  const maxIntentos = 24; // 4 minutos (cada 10 segundos)
  let intentos = 0;

  const intervalo = setInterval(async () => {
    intentos++;
    
    try {
      const estadoResult = await consultarTicketResumen(ticket);

      if (estadoResult.codigo === '98') {
        // Aún en proceso
        console.log(`Intento ${intentos}: En proceso...`);
        return;
      }

      // Respuesta definitiva
      clearInterval(intervalo);

      if (estadoResult.success) {
        toast.success(
          `Resumen aceptado por SUNAT. Código: ${estadoResult.codigo}`
        );
        
        if (estadoResult.notas && estadoResult.notas.length > 0) {
          console.warn('Observaciones:', estadoResult.notas);
        }
      } else {
        toast.error(
          `Rechazado por SUNAT: ${estadoResult.message}`
        );
      }
    } catch (error) {
      clearInterval(intervalo);
      toast.error(`Error al consultar: ${error.message}`);
    }
  }, 10000); // Cada 10 segundos

  // Timeout de 4 minutos
  setTimeout(() => {
    if (intentos < maxIntentos) {
      clearInterval(intervalo);
      toast.warning(
        'Tiempo de espera agotado. Consulte el ticket manualmente.'
      );
    }
  }, 240000);
};

Tabla de Control

Crear tabla para rastrear resumenes enviados:
CREATE TABLE resumenes_diarios (
    id INT PRIMARY KEY AUTO_INCREMENT,
    id_empresa INT NOT NULL,
    fecha_resumen DATE NOT NULL,
    correlativo VARCHAR(3) NOT NULL,
    ticket VARCHAR(100),
    nombre_archivo VARCHAR(255),
    cantidad_boletas INT,
    estado VARCHAR(20),             -- enviado, aceptado, rechazado
    codigo_sunat VARCHAR(10),
    mensaje_sunat TEXT,
    created_at TIMESTAMP,
    updated_at TIMESTAMP,
    UNIQUE KEY (id_empresa, fecha_resumen, correlativo)
);

Automatización con Cron

Para enviar automáticamente al final del día:
// app/Console/Kernel.php
protected function schedule(Schedule $schedule)
{
    // Enviar resumen diario a las 11:55 PM
    $schedule->call(function () {
        $empresas = Empresa::where('modo', 'production')->get();
        
        foreach ($empresas as $empresa) {
            $boletas = Venta::where('id_empresa', $empresa->id_empresa)
                ->whereHas('tipoDocumento', fn($q) => $q->where('cod_sunat', '03'))
                ->whereDate('fecha_emision', today())
                ->where('estado_sunat', '0')
                ->get();

            if ($boletas->isNotEmpty()) {
                $sunatService = app(SunatService::class);
                $resultado = $sunatService->resumenDiario(
                    $empresa,
                    $boletas->all(),
                    today()->toDateString()
                );

                Log::info('Resumen diario automático', [
                    'empresa' => $empresa->ruc,
                    'boletas' => $boletas->count(),
                    'ticket' => $resultado['ticket'] ?? null,
                ]);
            }
        }
    })->dailyAt('23:55');
}

Mejores Prácticas

  1. Enviar al final del día: No es necesario esperar hasta medianoche, puede ser a las 11 PM
  2. Validar antes de enviar: Asegurar que todas las boletas estén correctas
  3. Guardar tickets: Almacenar tickets para consultas posteriores
  4. Reintentos: Si el ticket falla, intentar con un nuevo resumen (nuevo correlativo)
  5. Notificaciones: Alertar al usuario si el resumen es rechazado

Build docs developers (and LLMs) love