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
Crear Venta
Registrar venta en la base de datos con cliente, productos, totales
Generar XML
Crear XML firmado digitalmente con Greenter
Enviar a SUNAT
Transmitir XML vía SOAP y recibir CDR
Procesar Respuesta
Guardar CDR y actualizar estado en base de datos
Generación de XML
Método Principal
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
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
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ódigo | Tipo | Longitud |
|---|
| 0 | Otros | Variable |
| 1 | DNI | 8 dígitos |
| 4 | Carnet de extranjería | Variable |
| 6 | RUC | 11 dígitos |
| 7 | Pasaporte | Variable |
Cálculo de Totales e IGV
$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.
$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
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ódigo | Descripción |
|---|
| 10 | Gravado - Operación Onerosa |
| 20 | Exonerado - Operación Onerosa |
| 30 | Inafecto - Operación Onerosa |
| 40 | Exportación |
Leyenda (Monto en Letras)
$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
$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
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)
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
$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ódigo | Descripción |
|---|
| 0 | Aceptado |
| 0001 | Aceptado con observaciones (no crítico) |
Códigos de Advertencia
| Código | Descripción |
|---|
| 2000-2999 | Advertencias (documento aceptado) |
Códigos de Rechazo
| Código | Descripción | Solución |
|---|
| 1033 | El documento ya existe | Ya fue enviado previamente |
| 2010 | Número de RUC del emisor no existe | Verificar RUC |
| 2011 | Número de RUC del receptor no existe | Verificar RUC del cliente |
| 2017 | Documento no cumple con el formato | Revisar estructura XML |
| 2200 | Firma digital inválida | Renovar certificado |
| 2324 | Serie no autorizada | Registrar serie en SUNAT |
| 4000 | Error interno SUNAT | Reintentar más tarde |
Manejo de Fechas y Zona Horaria
Greenter configura Twig con timezone America/Lima. Los DateTime DEBEN estar en esa zona horaria.
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);
}
};