Skip to main content

Visión General

El CDR (Constancia de Recepción) es la respuesta de SUNAT que confirma que un documento electrónico fue recibido y validado. Contiene el código de respuesta y observaciones.
Cada documento exitosamente enviado a SUNAT genera un CDR en formato ZIP que debe almacenarse para auditorías.

¿Qué es el CDR?

El CDR es un archivo XML firmado digitalmente por SUNAT que contiene:
  • Código de respuesta: 0 = Aceptado, otros = Rechazado/Observado
  • Descripción: Mensaje de SUNAT
  • Observaciones: Advertencias o errores no críticos
  • Hash del documento: Para verificar integridad
  • Fecha y hora de procesamiento

Estructura del CDR

Formato de Archivo

El CDR se entrega como archivo ZIP:
R-{RUC}-{TipoDoc}-{Serie}-{Numero}.zip
Ejemplos:
R-20612706702-01-F001-00000123.zip    # CDR de factura
R-20612706702-03-B001-00004567.zip    # CDR de boleta
R-20612706702-07-FC01-00000012.zip    # CDR de nota de crédito
Dentro del ZIP hay un archivo XML:
R-{RUC}-{TipoDoc}-{Serie}-{Numero}.xml

Contenido del XML

<?xml version="1.0" encoding="UTF-8"?>
<ApplicationResponse xmlns="urn:oasis:names:specification:ubl:schema:xsd:ApplicationResponse-2">
    <cbc:ResponseCode>0</cbc:ResponseCode>
    <cbc:ResponseDate>2026-03-06</cbc:ResponseDate>
    <cbc:ResponseTime>14:30:25</cbc:ResponseTime>
    <cac:DocumentResponse>
        <cac:Response>
            <cbc:ResponseCode>0</cbc:ResponseCode>
            <cbc:Description>La Factura numero F001-00000123, ha sido aceptada</cbc:Description>
        </cac:Response>
    </cac:DocumentResponse>
    <cac:SignatureInformation>
        <!-- Firma digital de SUNAT -->
    </cac:SignatureInformation>
</ApplicationResponse>

Procesamiento del CDR

En Envío de Facturas

SunatService.php:271
if ($result->isSuccess()) {
    $cdr = $result->getCdrResponse();
    $cdrZip = $result->getCdrZip();

    // Guardar CDR en storage
    $cdrDir = storage_path("app/sunat/cdr/{$ruc}");
    if (!is_dir($cdrDir)) {
        mkdir($cdrDir, 0755, true);
    }
    file_put_contents("{$cdrDir}/R-{$nombreArchivo}.zip", $cdrZip);

    // Actualizar venta con información del CDR
    $venta->update([
        'estado_sunat' => '1',              // Aceptado
        'cdr_url' => "sunat/cdr/{$ruc}/R-{$nombreArchivo}.zip",
        'codigo_sunat' => $cdr->getCode(),  // "0" para aceptado
        'mensaje_sunat' => $cdr->getDescription(),
    ]);

    return [
        'success' => true,
        'codigo' => $cdr->getCode(),
        'mensaje' => $cdr->getDescription(),
        'cdr_url' => "sunat/cdr/{$ruc}/R-{$nombreArchivo}.zip",
    ];
}

Métodos del Objeto CDR

El objeto $cdr (CdrResponse) proporciona:
$cdr->getCode();           // Código de respuesta: "0", "2000", etc.
$cdr->getDescription();    // Descripción: "La Factura ... ha sido aceptada"
$cdr->getNotes();          // Array de observaciones (puede ser null)
$cdr->getId();             // ID del documento
$cdr->getReference();      // Referencia al documento original

Códigos de Respuesta

Códigos de Éxito

Código 0

AceptadoEl documento fue aceptado sin observaciones

Código 0001-0999

Aceptado con ObservacionesDocumento aceptado pero con advertencias menores

Códigos de Advertencia (2000-2999)

Documento aceptado pero con observaciones que deben corregirse:
CódigoDescripción
2000Número de RUC del receptor no existe
2001DNI del receptor no existe
2002Número de documento duplicado
2010El número del documento ya existe
2324La serie no corresponde al tipo de comprobante
Los códigos 2XXX indican que el documento fue aceptado, pero hay inconsistencias que SUNAT registra para auditoría.

Códigos de Rechazo (>= 4000)

Documento rechazado, no válido:
CódigoDescripciónSolución
1033El documento ya fue enviado previamenteNo reenviar
2017El documento no cumple con el formatoRevisar estructura XML
2200La firma digital no correspondeRenovar certificado
2800El comprobante no cumple con el grupo de ítemsRevisar detalles
4000Error interno de SUNATReintentar más tarde

Consulta de CDR Mediante Ticket

Para documentos asíncronos (guías, resumen diario, comunicación de baja):

Consultar Estado

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',
    ];
}

Descarga de CDR

Endpoint de Descarga

Route::get('/descargar-cdr/{id}', [VentasController::class, 'descargarCdr'])
    ->middleware(['auth', 'token.from.query'])
    ->name('descargar.cdr');

Controlador

public function descargarCdr($id)
{
    $venta = Venta::findOrFail($id);
    
    // Verificar permisos
    if ($venta->id_empresa !== auth()->user()->id_empresa) {
        abort(403, 'No autorizado');
    }

    if (!$venta->cdr_url) {
        abort(404, 'CDR no disponible');
    }

    $cdrPath = storage_path("app/{$venta->cdr_url}");

    if (!file_exists($cdrPath)) {
        abort(404, 'Archivo CDR no encontrado');
    }

    return response()->download(
        $cdrPath,
        "R-{$venta->serie}-{$venta->numero}.zip",
        ['Content-Type' => 'application/zip']
    );
}

Desde el Frontend

import { baseUrl } from '@/lib/baseUrl';

const descargarCDR = (ventaId) => {
  const token = localStorage.getItem('auth_token');
  const url = baseUrl(`/descargar-cdr/${ventaId}?token=${token}`);
  window.open(url, '_blank');
};

Extracción de Hash del XML

El hash se utiliza para validar la integridad del documento:
SunatService.php:1050
private function getHashFromXml(string $xml): ?string
{
    // Usar clase de Greenter si está disponible
    if (class_exists(\Greenter\Report\XmlUtils::class)) {
        return (new \Greenter\Report\XmlUtils())->getHashSign($xml);
    }

    // Extraer manualmente del XML
    preg_match('/<ds:DigestValue>([^<]+)<\/ds:DigestValue>/', $xml, $matches);
    return $matches[1] ?? null;
}
El hash se almacena en la tabla ventas como hash_cpe.

Validación del CDR

Verificar Firma Digital

El CDR está firmado digitalmente por SUNAT. Para validar:
use Greenter\Report\XmlUtils;

$cdrPath = storage_path("app/sunat/cdr/{$ruc}/R-{$nombreArchivo}.zip");

// Extraer XML del ZIP
$zip = new \ZipArchive();
$zip->open($cdrPath);
$xmlContent = $zip->getFromIndex(0);
$zip->close();

// Validar firma
$xmlUtils = new XmlUtils();
$isValid = $xmlUtils->verifyXmlSignature($xmlContent);

if ($isValid) {
    echo "Firma digital válida";
} else {
    echo "Firma digital inválida o CDR adulterado";
}
Si la firma digital del CDR no es válida, el archivo puede haber sido modificado después de su emisión por SUNAT.

Consulta CDR en Portal SUNAT

SUNAT permite consultar el estado de documentos manualmente:
  1. Ir a: https://www.sunat.gob.pe/ol-ti-itconsvalicpe/ConsValiCpe.htm
  2. Ingresar:
    • RUC del emisor
    • Tipo de documento
    • Serie y número
    • Fecha de emisión
    • Monto total
  3. Ver estado y descargar CDR
Esta consulta es útil para verificar documentos cuando el CDR no se guardó correctamente en el sistema.

Regeneración de CDR

Si el CDR se perdió, se puede volver a consultar:
public function regenerarCdr($id)
{
    $venta = Venta::findOrFail($id);
    
    if ($venta->estado_sunat !== '1') {
        return ['success' => false, 'message' => 'Solo se pueden regenerar CDR de documentos aceptados'];
    }

    $empresa = $venta->empresa;
    $ruc = $empresa->modo !== 'production' 
        ? config('sunat.beta.ruc') 
        : $empresa->ruc;

    $xmlPath = storage_path("app/{$venta->xml_url}");
    
    if (!file_exists($xmlPath)) {
        return ['success' => false, 'message' => 'XML no encontrado'];
    }

    $xmlContent = file_get_contents($xmlPath);
    $nombreArchivo = pathinfo($xmlPath, PATHINFO_FILENAME);

    $see = app(SunatService::class)->getSee($empresa);
    
    // Reenviar (SUNAT retorna el CDR aunque el documento ya exista)
    $result = $see->sendXml(Invoice::class, $nombreArchivo, $xmlContent);

    if ($result->isSuccess()) {
        $cdrZip = $result->getCdrZip();
        $cdrDir = storage_path("app/sunat/cdr/{$ruc}");
        
        if (!is_dir($cdrDir)) {
            mkdir($cdrDir, 0755, true);
        }
        
        file_put_contents("{$cdrDir}/R-{$nombreArchivo}.zip", $cdrZip);
        
        $venta->update([
            'cdr_url' => "sunat/cdr/{$ruc}/R-{$nombreArchivo}.zip",
        ]);

        return [
            'success' => true,
            'message' => 'CDR regenerado exitosamente',
        ];
    }

    return [
        'success' => false,
        'message' => 'Error al regenerar CDR',
    ];
}

Almacenamiento y Backup

Estructura de Directorios

storage/app/sunat/
├── xml/
│   └── 20612706702/
│       ├── 20612706702-01-F001-00000123.xml
│       └── 20612706702-03-B001-00000045.xml
├── cdr/
│   └── 20612706702/
│       ├── R-20612706702-01-F001-00000123.zip
│       ├── R-20612706702-03-B001-00000045.zip
│       └── R-ticket-ABC123.zip
└── certificados/
    └── 20612706702-cert.pem

Backup Automático

// app/Console/Commands/BackupSunatFiles.php
namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;

class BackupSunatFiles extends Command
{
    protected $signature = 'sunat:backup';
    protected $description = 'Backup de archivos XML y CDR';

    public function handle()
    {
        $fecha = now()->format('Y-m-d');
        $backupPath = "backups/sunat-{$fecha}.zip";

        $zip = new \ZipArchive();
        $zipPath = storage_path("app/{$backupPath}");

        if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE)) {
            // Añadir todos los archivos SUNAT
            $sunatPath = storage_path('app/sunat');
            $this->addDirectoryToZip($zip, $sunatPath, 'sunat/');
            $zip->close();

            $this->info("Backup creado: {$backupPath}");
            
            // Opcional: Subir a S3, Google Drive, etc.
            // Storage::disk('s3')->put($backupPath, file_get_contents($zipPath));
        } else {
            $this->error('Error al crear backup');
        }
    }

    private function addDirectoryToZip($zip, $dir, $zipPath)
    {
        $files = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($dir),
            \RecursiveIteratorIterator::LEAVES_ONLY
        );

        foreach ($files as $file) {
            if (!$file->isDir()) {
                $filePath = $file->getRealPath();
                $relativePath = $zipPath . substr($filePath, strlen($dir) + 1);
                $zip->addFile($filePath, $relativePath);
            }
        }
    }
}
Programar en app/Console/Kernel.php:
protected function schedule(Schedule $schedule)
{
    // Backup diario a las 2 AM
    $schedule->command('sunat:backup')->dailyAt('02:00');
}

Vista de CDR en el Frontend

import { DownloadIcon, CheckCircleIcon, ExclamationCircleIcon } from 'lucide-react';

const CdrStatus = ({ venta }) => {
  const getStatusIcon = () => {
    if (!venta.codigo_sunat) return null;
    
    const codigo = parseInt(venta.codigo_sunat);
    
    if (codigo === 0) {
      return <CheckCircleIcon className="text-green-500" />;
    } else if (codigo < 4000) {
      return <ExclamationCircleIcon className="text-yellow-500" />;
    } else {
      return <XCircleIcon className="text-red-500" />;
    }
  };

  return (
    <div className="flex items-center gap-2">
      {getStatusIcon()}
      <div>
        <p className="text-sm font-medium">
          Código: {venta.codigo_sunat || 'N/A'}
        </p>
        <p className="text-xs text-gray-600">
          {venta.mensaje_sunat || 'Sin respuesta'}
        </p>
      </div>
      
      {venta.cdr_url && (
        <button
          onClick={() => descargarCDR(venta.id_venta)}
          className="ml-auto btn btn-sm btn-ghost"
        >
          <DownloadIcon className="w-4 h-4" />
          Descargar CDR
        </button>
      )}
    </div>
  );
};

Observaciones del CDR

Algunas respuestas incluyen observaciones adicionales:
$notas = $cdr->getNotes();

if ($notas && count($notas) > 0) {
    foreach ($notas as $nota) {
        Log::warning('Observación SUNAT', [
            'venta' => $venta->id_venta,
            'observacion' => $nota,
        ]);
    }
}
Ejemplo de observaciones:
  • “El número de RUC del receptor no existe en el padrón”
  • “El documento de identidad del receptor no existe”
  • “La serie y número del comprobante ya fue informado anteriormente”

Troubleshooting

Causa: Permisos de escritura incorrectos en storage/app/sunat/cdr/.Solución:
chmod -R 775 storage/app/sunat/cdr
chown -R www-data:www-data storage/app/sunat/cdr
Causa: Descarga incompleta o error de red.Solución: Usar el método regenerarCdr() para volver a obtener el CDR de SUNAT.
Causa: La serie no está registrada en SUNAT.Solución: Ingresar al portal SUNAT y dar de alta la serie antes de usarla.
Causa: Token de autenticación no se pasa correctamente.Solución: Usar middleware TokenFromQuery que acepta ?token= en la URL.

Retención de CDR

Los CDR deben conservarse por el plazo de prescripción tributaria (4 años según legislación peruana) para auditorías de SUNAT.

Política de Retención

// Eliminar CDR de más de 5 años
public function limpiarCdrAntiguos()
{
    $fechaLimite = now()->subYears(5);
    
    $ventasAntiguas = Venta::where('fecha_emision', '<', $fechaLimite)
        ->whereNotNull('cdr_url')
        ->get();

    foreach ($ventasAntiguas as $venta) {
        $cdrPath = storage_path("app/{$venta->cdr_url}");
        
        if (file_exists($cdrPath)) {
            unlink($cdrPath);
            $venta->update(['cdr_url' => null]);
        }
    }
    
    return $ventasAntiguas->count();
}

Build docs developers (and LLMs) love