Skip to main content

Visión General

El módulo de facturación electrónica genera XML firmados con Greenter y los envía a SUNAT mediante SOAP, procesando las respuestas CDR.
El proceso tiene dos etapas: 1) Generar XML, 2) Enviar a SUNAT. Esto permite pre-generar documentos y enviarlos posteriormente.

Flujo de Facturación

1

Crear Venta

Registrar venta en la base de datos con cliente, productos, totales
2

Generar XML

Crear XML firmado digitalmente con Greenter
3

Enviar a SUNAT

Transmitir XML vía SOAP y recibir CDR
4

Procesar Respuesta

Guardar CDR y actualizar estado en base de datos

Generación de XML

Método Principal

SunatService.php:159
public function generarXml(Venta $venta): array
{
    $venta->load(['cliente', 'empresa', 'productosVentas', 'tipoDocumento', 'cuotas']);
    $empresa = $venta->empresa;
    $cliente = $venta->cliente;

    $codSunat = $venta->tipoDocumento->cod_sunat;  // '01' o '03'
    $igvRate = (float) ($empresa->igv ?? config('sunat.igv'));  // 0.18

    $company = $this->buildCompany($empresa);
    $client = $this->buildClient($cliente);
    
    // ... cálculo de totales ...
}

Construcción de la Empresa

SunatService.php:109
public function buildCompany(Empresa $empresa): Company
{
    $company = new Company();
    $company->setRuc($empresa->modo !== 'production' ? config('sunat.beta.ruc') : $empresa->ruc)
        ->setNombreComercial($empresa->razon_social)
        ->setRazonSocial($empresa->razon_social)
        ->setAddress((new Address())
            ->setUbigueo($empresa->ubigeo ?? '150101')       // Lima-Lima-Lima
            ->setDistrito($empresa->distrito ?? '-')
            ->setProvincia($empresa->provincia ?? '-')
            ->setDepartamento($empresa->departamento ?? '-')
            ->setUrbanizacion('-')
            ->setCodLocal('0000')                             // Código de establecimiento
            ->setDireccion($empresa->direccion ?? '-'));

    return $company;
}
En modo beta, se usa el RUC 20000000001 en lugar del RUC real de la empresa.

Construcción del Cliente

SunatService.php:127
public function buildClient(object $cliente): Client
{
    $documento = $cliente->documento ?? '';
    $tipoDoc = '0';
    $numDoc = '00000000';

    // Detección automática de tipo de documento
    if (!empty($cliente->tipo_doc) && strlen($documento) > 0) {
        $tipoDoc = $cliente->tipo_doc;
        $numDoc = $documento;
    } elseif (strlen($documento) === 11) {
        $tipoDoc = '6'; // RUC
        $numDoc = $documento;
    } elseif (strlen($documento) === 8) {
        $tipoDoc = '1'; // DNI
        $numDoc = $documento;
    } elseif (strlen($documento) > 0) {
        $tipoDoc = '4'; // Carnet de Extranjería
        $numDoc = $documento;
    }

    $client = new Client();
    $client->setTipoDoc($tipoDoc)
        ->setNumDoc($numDoc)
        ->setRznSocial($cliente->datos ?? 'cliente');

    if (!empty($cliente->direccion)) {
        $client->setAddress((new Address())->setDireccion($cliente->direccion));
    }

    return $client;
}
Tipos de documento de identidad:
CódigoTipoLongitud
0OtrosVariable
1DNI8 dígitos
4Carnet de extranjeríaVariable
6RUC11 dígitos
7PasaporteVariable

Cálculo de Totales e IGV

SunatService.php:171
$total = (float) $venta->total;
$apliIgv = (float) $venta->igv > 0;  // true si la venta tiene IGV

// Cálculo inversivo del IGV (desde el total)
$montoGravada = $apliIgv ? round($total / ($igvRate + 1), 2) : 0;
$igvMonto = $apliIgv ? round($total / ($igvRate + 1) * $igvRate, 2) : 0;
$impVenta = round($total, 2);

if ($apliIgv) {
    $invoice->setMtoOperGravadas($montoGravada)    // Base imponible
        ->setMtoIGV($igvMonto)                      // Monto de IGV
        ->setTotalImpuestos($igvMonto)
        ->setValorVenta($montoGravada)
        ->setMtoImpVenta($impVenta)                 // Total incluyendo IGV
        ->setSubTotal($impVenta);
} else {
    // Operación exonerada (sin IGV)
    $invoice->setMtoOperExoneradas($impVenta)
        ->setMtoIGV(0)
        ->setTotalImpuestos(0)
        ->setValorVenta($impVenta)
        ->setMtoImpVenta($impVenta)
        ->setSubTotal($impVenta);
}
El sistema asume que los precios ya incluyen IGV. Para obtener la base imponible, divide entre 1.18.

Forma de Pago y Cuotas

SunatService.php:205
$tipoPago = $venta->id_tipo_pago ?? '1';  // 1=Contado, 2=Crédito

$invoice->setFormaPago(
    $tipoPago == '1' 
        ? new FormaPagoContado() 
        : new FormaPagoCredito($impVenta)
);

if ($venta->fecha_vencimiento) {
    $invoice->setFecVencimiento($venta->fecha_vencimiento);
}

// Cuotas para crédito
if ($tipoPago != '1' && $venta->cuotas && $venta->cuotas->count() > 0) {
    $cuotasGreenter = [];
    foreach ($venta->cuotas as $cuota) {
        $cuotasGreenter[] = (new Cuota())
            ->setMonto((float) $cuota->monto)
            ->setFechaPago(\DateTime::createFromFormat('Y-m-d', $cuota->fecha));
    }
    $invoice->setCuotas($cuotasGreenter);
}

Detalle de Productos

SunatService.php:956
private function buildSaleDetails(Venta $venta, float $igvRate, bool $apliIgv): array
{
    $details = [];

    foreach ($venta->productosVentas as $item) {
        $precio = (float) $item->precio_unitario;
        $cantidad = (float) $item->cantidad;

        $detail = new SaleDetail();
        $detail->setCodProducto($item->codigo_producto ?? 'P001')
            ->setCodProdSunat('10000000')           // Catálogo SUNAT genérico
            ->setUnidad($item->unidad_medida ?? 'NIU')  // NIU = Unidad
            ->setDescripcion($item->descripcion ?? 'Producto')
            ->setCantidad($cantidad);

        if ($apliIgv) {
            $valorUnitario = round($precio / ($igvRate + 1), 2);
            $valorVenta = round($precio * $cantidad / ($igvRate + 1), 2);
            $igvItem = round($precio * $cantidad / ($igvRate + 1) * $igvRate, 2);

            $detail->setMtoValorUnitario($valorUnitario)
                ->setMtoValorVenta($valorVenta)
                ->setMtoBaseIgv($valorVenta)
                ->setPorcentajeIgv($igvRate * 100)  // 18.00
                ->setIgv($igvItem)
                ->setTipAfeIgv($item->tipo_afectacion_igv ?? '10')  // Gravado
                ->setTotalImpuestos($igvItem)
                ->setMtoPrecioUnitario($precio);    // Precio con IGV
        } else {
            $detail->setMtoValorUnitario($precio)
                ->setMtoValorVenta(round($precio * $cantidad, 2))
                ->setMtoBaseIgv(round($precio * $cantidad, 2))
                ->setPorcentajeIgv(0)
                ->setIgv(0)
                ->setTipAfeIgv('20')                // Exonerado
                ->setTotalImpuestos(0)
                ->setMtoPrecioUnitario($precio);
        }

        $details[] = $detail;
    }

    return $details;
}
Tipos de afectación al IGV:
CódigoDescripción
10Gravado - Operación Onerosa
20Exonerado - Operación Onerosa
30Inafecto - Operación Onerosa
40Exportación

Leyenda (Monto en Letras)

SunatService.php:225
$invoice->setLegends([
    (new Legend())
        ->setCode('1000')
        ->setValue('SON ' . strtoupper($this->numberToWords($total)) . ' SOLES')
]);
La función numberToWords() convierte números a texto en español:
numberToWords(150.50) 
// "CIENTO CINCUENTA CON 50/100"

Firma Digital y Guardado

SunatService.php:231
$see = $this->getSee($empresa);
$xmlContent = $see->getXmlSigned($invoice);  // Firma con certificado digital
$nombreArchivo = $invoice->getName();         // 20612706702-01-F001-00000123

$this->guardarXml($empresa, $nombreArchivo, $xmlContent);

$hash = $this->getHashFromXml($xmlContent);  // Extraer hash de firma digital

$ruc = $this->getRuc($empresa);

return [
    'success' => true,
    'nombre_archivo' => $nombreArchivo,
    'hash' => $hash,
    'xml_url' => "sunat/xml/{$ruc}/{$nombreArchivo}.xml",
];

Envío a SUNAT

Método de Envío

SunatService.php:249
public function enviarComprobante(Venta $venta): array
{
    $venta->load(['empresa', 'tipoDocumento']);
    $empresa = $venta->empresa;
    $ruc = $this->getRuc($empresa);

    // Buscar XML previamente generado
    if ($venta->xml_url) {
        $xmlPath = storage_path("app/{$venta->xml_url}");
    } else {
        $codSunat = $venta->tipoDocumento->cod_sunat ?? '01';
        $posibleNombre = "{$ruc}-{$codSunat}-{$venta->serie}-{$venta->numero}";
        $xmlPath = storage_path("app/sunat/xml/{$ruc}/{$posibleNombre}.xml");
    }

    if (!file_exists($xmlPath)) {
        return ['success' => false, 'message' => 'XML no encontrado. Genere el XML primero.'];
    }

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

    $see = $this->getSee($empresa);
    $result = $see->sendXml(Invoice::class, $nombreArchivo, $xmlContent);

    // Procesar respuesta ...
}

Procesamiento de CDR (Respuesta SUNAT)

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

    // Guardar CDR
    $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
    $venta->update([
        'estado_sunat' => '1',              // Aceptado
        'hash_cpe' => $venta->hash_cpe,
        'cdr_url' => "sunat/cdr/{$ruc}/R-{$nombreArchivo}.zip",
        'codigo_sunat' => $cdr->getCode(),  // Generalmente "0" para aceptado
        'mensaje_sunat' => $cdr->getDescription(),
    ]);

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

Manejo de Errores

SunatService.php:298
$error = $result->getError();
Log::error('SUNAT - Comprobante rechazado', [
    'venta' => $venta->serie . '-' . $venta->numero,
    'codigo' => $error->getCode(),
    'mensaje' => $error->getMessage(),
]);

$venta->update([
    'estado_sunat' => '3',              // Rechazado
    'codigo_sunat' => $error->getCode(),
    'mensaje_sunat' => $error->getMessage(),
    'intentos' => ($venta->intentos ?? 0) + 1,
]);

return [
    'success' => false,
    'codigo' => $error->getCode(),
    'message' => $error->getMessage(),
];

Códigos de Respuesta SUNAT

Códigos de Éxito

CódigoDescripción
0Aceptado
0001Aceptado con observaciones (no crítico)

Códigos de Advertencia

CódigoDescripción
2000-2999Advertencias (documento aceptado)

Códigos de Rechazo

CódigoDescripciónSolución
1033El documento ya existeYa fue enviado previamente
2010Número de RUC del emisor no existeVerificar RUC
2011Número de RUC del receptor no existeVerificar RUC del cliente
2017Documento no cumple con el formatoRevisar estructura XML
2200Firma digital inválidaRenovar certificado
2324Serie no autorizadaRegistrar serie en SUNAT
4000Error interno SUNATReintentar más tarde

Manejo de Fechas y Zona Horaria

Greenter configura Twig con timezone America/Lima. Los DateTime DEBEN estar en esa zona horaria.
SunatService.php:88
private function fechaParaGreenter($fechaRaw, $createdAt = null): \DateTime
{
    $peruTz = new \DateTimeZone('America/Lima');
    $fechaStr = substr((string) ($fechaRaw ?? date('Y-m-d')), 0, 10);

    // Usar hora de creación real en Perú, o 08:00 por defecto
    $hora = '08:00:00';
    if ($createdAt) {
        try {
            $dt = $createdAt instanceof \DateTimeInterface
                ? (new \DateTime($createdAt->format('Y-m-d H:i:s'), new \DateTimeZone('UTC')))->setTimezone($peruTz)
                : (new \DateTime((string) $createdAt))->setTimezone($peruTz);
            $hora = $dt->format('H:i:s');
        } catch (\Exception $e) {
            // fallback
        }
    }

    return new \DateTime("{$fechaStr} {$hora}", $peruTz);
}

Almacenamiento de Archivos

storage/app/sunat/
├── xml/
│   └── 20612706702/
│       ├── 20612706702-01-F001-00000123.xml
│       ├── 20612706702-01-F001-00000124.xml
│       └── 20612706702-03-B001-00000045.xml
├── cdr/
│   └── 20612706702/
│       ├── R-20612706702-01-F001-00000123.zip
│       └── R-20612706702-01-F001-00000124.zip
└── certificados/
    ├── 20612706702-cert.pem
    └── cert.pem  (prueba global)

Integración en Controladores

use App\Services\SunatService;

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

    public function generarXml($id)
    {
        $venta = Venta::findOrFail($id);
        $resultado = $this->sunatService->generarXml($venta);
        
        if ($resultado['success']) {
            $venta->update([
                'xml_url' => $resultado['xml_url'],
                'hash_cpe' => $resultado['hash'],
                'nombre_xml' => $resultado['nombre_archivo'],
            ]);
        }
        
        return response()->json($resultado);
    }

    public function enviarSunat($id)
    {
        $venta = Venta::findOrFail($id);
        $resultado = $this->sunatService->enviarComprobante($venta);
        
        return response()->json($resultado);
    }
}

Proceso Completo desde el Frontend

import { generarXmlVenta, enviarVentaSunat } from '@/services/ventas';

const procesarVenta = async (ventaId) => {
  try {
    // Paso 1: Generar XML
    const xmlResult = await generarXmlVenta(ventaId);
    if (!xmlResult.success) {
      toast.error('Error al generar XML: ' + xmlResult.message);
      return;
    }
    toast.success('XML generado exitosamente');

    // Paso 2: Enviar a SUNAT
    const sunatResult = await enviarVentaSunat(ventaId);
    if (!sunatResult.success) {
      toast.error('Rechazado por SUNAT: ' + sunatResult.message);
      return;
    }

    toast.success(
      `Aceptado por SUNAT - Código: ${sunatResult.codigo}`
    );
  } catch (error) {
    toast.error('Error: ' + error.message);
  }
};

Build docs developers (and LLMs) love