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:
Reducir tráfico : Enviar cientos de boletas juntas en lugar de una por una
Simplificar : Un solo envío para todo el día
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
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
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
$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:
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ódigo Estado Acción 0 Aceptado Resumen procesado exitosamente 98 En proceso Consultar nuevamente en 5-10 segundos 99 Procesado con observaciones Revisar notas del CDR 4XXX Error de validación Corregir y reenviar
Resumen Diario de Baja
Para anular boletas previamente enviadas:
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
Emitir Boletas Durante el Día
Generar boletas normalmente, sin envío inmediato a SUNAT
Al Final del Día: Agrupar Boletas
Obtener todas las boletas del día que aún no han sido enviadas
Generar Resumen Diario
Crear documento Summary con todas las boletas
Enviar a SUNAT
Transmitir vía SOAP, recibir ticket
Consultar Ticket (Polling)
Consultar cada 10 segundos hasta obtener CDR o error
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
Enviar al final del día : No es necesario esperar hasta medianoche, puede ser a las 11 PM
Validar antes de enviar : Asegurar que todas las boletas estén correctas
Guardar tickets : Almacenar tickets para consultas posteriores
Reintentos : Si el ticket falla, intentar con un nuevo resumen (nuevo correlativo)
Notificaciones : Alertar al usuario si el resumen es rechazado