Facturador Electronico SUNAT | Automata
secciones (9)
$ cd .. // volver a proyectos
automata@latam: ~/proyectos/facturador-sunat
PRODUCTO · slug: facturador-sunat ·2026 · 5 min read · 1,142 words

> Facturador Electronico SUNAT

// SaaS multi-tenant de facturacion electronica para Peru: emite los seis comprobantes SUNAT via Greenter con cola async, aislamiento por tenant y API REST publica.

Laravel 11Filament 3MySQL 8Greenter 5PHP 8.3PestMercadoPago
Facturador Electronico SUNAT
▸ rol
Founder · Full-stack · Integracion SUNAT
▸ equipo
Solo
▸ status
ready
// section 01 · descubrimiento

$ cat ./descubrimiento.md

▸ descripcion

SaaS para que una pyme peruana emita Factura (01), Boleta (03), Nota de Credito (07), Nota de Debito (08), Guia de Remision (09) y Comunicacion de Baja (RA) directo a SUNAT con firma XML real via Greenter. Cada user registra sus RUC y la data del tenant (clientes, productos, comprobantes) queda aislada por global scope. Las llamadas a SUNAT corren en cola, asi la UI responde en <500ms sin importar cuanto tarda SUNAT. Un modo SUNAT_FAKE firma XML real contra un CDR simulado para demostrar el flujo offline.

▸ problema

La facturacion electronica en Peru es obligatoria pero los SaaS existentes cobran 50-400 USD/mes por usuario. Las pymes pagan un proveedor en vez de enfocarse en operar, y las pocas alternativas open requieren mucho trabajo de integracion.

▸ audiencia

Micro y pequeñas empresas peruanas y freelancers (4ta categoria) con RUC que necesitan facturacion electronica conforme a SUNAT sin pagar precios enterprise.

// section 03 · arquitectura

$ cat ./arquitectura.md

c4 / contexto · c4-level-1
loading…
// section 04 · infraestructura

$ cat ./infraestructura.md

▸ servicios
provider: Laravel 11 + Filament 3 + MySQL 8 + Greenter
  • Panel admin Filament (Livewire por debajo)
  • MySQL 8 (multi-tenant, scoping por RUC del user)
  • Worker de cola (driver database, envio async a SUNAT)
  • Scheduler (heartbeat horario de status page, batch nocturno de Comunicacion de Baja)
  • MercadoPago Checkout Pro + webhook (HMAC SHA-256 firmado)
  • Sentry hook + endpoint /health
  • Cola de email (PDF + CDR al cliente cuando SUNAT acepta)
  • Sitio de docs en VitePress (docs-site/)
  • SDK PHP (sdk/) para la API REST publica
// section 05 · implementacion

$ cat ./implementacion.md

▸ frontend
  • · Filament 3
  • · Livewire 3 (via Filament)
  • · Alpine.js
  • · Tailwind (via Filament)
  • · VitePress (docs publicas)
▸ backend
  • · Laravel 11
  • · PHP 8.3
  • · Greenter 5.2 (firma XML + SOAP)
  • · spatie/activitylog (auditoria)
  • · maatwebsite/excel (import masivo)
  • · mPDF
  • · Google2FA (TOTP)
  • · Sanctum (tokens API)
▸ datos
  • · MySQL 8
  • · Eloquent + global scopes para aislamiento por tenant
  • · Cascade + soft deletes para recuperacion
  • · Enums string para codigos SUNAT (CodigoDetraccion, EstadoComprobante, etc.)
▸ devops
  • · GitHub Actions (Pest + Pint sobre PHP 8.3 + MySQL 8.4)
  • · Laragon en local
  • · Sentry
// section 06 · desafios tecnicos

$ cat ./challenges/*.md

// 4 problemas tecnicos resueltos

01 / 04
challenge-01.md · multi-tenant · aislamiento · idor
▸ problema

Cada user autenticado debe ver solo sus comprobantes, clientes y productos. Un WHERE que falta filtra data entre tenants.

restriccion: Los global scopes se aplican automaticamente en requests autenticados pero NO en comandos CLI ni workers, que legitimamente necesitan operar sobre todos los tenants. El bypass debe ser explicito, auditable y testeado.

▸ enfoque

PerteneceAEmpresasDelUsuarioScope se aplica a 12 modelos. Lee Auth::user() en tiempo de query y filtra por empresa_id IN (SELECT id FROM empresas WHERE user_id = $userId). Workers y seeders usan withoutGlobalScope() explicitamente. AislamientoTest.php tiene 8 casos (aislamiento user A/B, lookup por ID directo, import CSV, bypass sin auth).

app/Models/Scopes/PerteneceAEmpresasDelUsuarioScope.php php
public function apply(Builder $builder, Model $model): void
{
    $user = Auth::user();
    if (! $user) {
        return; // CLI / jobs ven todo — el bypass es explicito en otro lado
    }
    $builder->whereIn(
        "{$model->getTable()}.empresa_id",
        Empresa::where('user_id', $user->id)->select('id')
    );
}
challenge-02.md · async · queue · sunat
▸ problema

SUNAT puede tardar 30s+ en responder un comprobante. Si el request web espera, la UI se congela y los users ven HTTP 502.

restriccion: No se puede bloquear el row del comprobante durante el envio (riesgo de deadlock, visibilidad parcial en caso de fallo). La idempotencia importa: un job reintentado no debe emitir dos veces.

▸ enfoque

EmitirComprobante (el use case) asigna correlativo, recalcula totales, marca estado = ENCOLADO y retorna en <500ms. El job EnviarComprobanteASunat corre en cola, llama a SunatService->enviar() y actualiza el estado a ACEPTADO o RECHAZADO. El job es idempotente: si el estado ya es terminal, salta. tries=3, backoff=30s. SUNAT_FAKE=true cambia SunatService por uno que simula un CDR valido — el resto del pipeline (PDF, email, auditoria) corre igual.

app/Jobs/EnviarComprobanteASunat.php php
final class EnviarComprobanteASunat implements ShouldQueue
{
    public int $tries = 3;
    public int $backoff = 30;

    public function handle(DocumentBuilderService $builder, SunatService $sunat): void
    {
        $comprobante = Comprobante::find($this->comprobanteId);

        // Idempotencia: salta si ya esta procesado
        if ($comprobante->estado->esEmitido()
            || $comprobante->estado === EstadoComprobante::RECHAZADO) {
            return;
        }

        $sunat->enviar(
            comprobante: $comprobante,
            document: $builder->build(comprobante: $comprobante)
        );
    }
}
challenge-03.md · sunat · impuestos · detracciones
▸ problema

Las Detracciones (SPOT) son una retencion obligatoria con 10+ categorias, cada una con su porcentaje (4-12%) y umbral minimo (S/ 400 o S/ 700). Si el porcentaje o el umbral estan mal, SUNAT rechaza el comprobante.

restriccion: La regla debe ser automatica (el user no deberia conocer el catalogo) pero transparente (debe mostrarse en el form para que pueda explicarlo al cliente). Umbrales y porcentajes no pueden estar hardcodeados en varios lugares.

▸ enfoque

CodigoDetraccion es un enum PHP 8: un case por codigo SUNAT, con metodos porcentaje() y umbralMinimo(). El form muestra el fieldset de Detraccion solo cuando monto_total >= umbral del codigo elegido. Los tests verifican boundary values (S/ 699 = sin detraccion, S/ 700 = aplica).

app/Enums/CodigoDetraccion.php php
enum CodigoDetraccion: string
{
    case ARRENDAMIENTO_BIENES         = '019'; // 10%
    case MANTENIMIENTO_REPARACION     = '020'; // 12%
    case MOVIMIENTO_CARGA             = '021'; // 10%
    case OTROS_SERVICIOS_EMPRESARIALES = '022'; // 4%
    // ...

    public function porcentaje(): float
    {
        return match ($this) {
            self::ARRENDAMIENTO_BIENES,
            self::MOVIMIENTO_CARGA            => 10.00,
            self::MANTENIMIENTO_REPARACION    => 12.00,
            self::OTROS_SERVICIOS_EMPRESARIALES => 4.00,
        };
    }

    public function umbralMinimo(): float
    {
        return match ($this) {
            self::OTROS_SERVICIOS_EMPRESARIALES => 400.00,
            default                              => 700.00,
        };
    }
}
challenge-04.md · api-externa · cache · fallback
▸ problema

Las facturas en USD necesitan el tipo de cambio oficial SUNAT del dia. apis.net.pe es la fuente publica y puede estar lenta o caida (≈30% de fallas en dias pico).

restriccion: No se puede cachear mas de un dia (cambia diario). No se puede usar el tipo de cambio de ayer si hoy falla (riesgo de auditoria). No se puede bloquear la emision si la API no responde.

▸ enfoque

TipoCambioService envuelve apis.net.pe con timeout HTTP de 5s. Si funciona: cachea por un dia bajo tc:usd:YYYY-MM-DD. Si falla: cae al ultimo tipo cacheado en los ultimos 30 dias. Ultimo fallback: 3.75 (aproximacion segura). El form auto-completa al cargar y tiene un boton manual de refresh.

app/Domain/Cambio/TipoCambioService.php php
public function obtener(?Carbon $fecha = null): float
{
    $fecha ??= now();
    $key   = 'tc:usd:' . $fecha->format('Y-m-d');

    return Cache::remember($key, now()->addDay(), function () use ($fecha) {
        try {
            $resp = Http::timeout(5)->get(
                'https://api.apis.net.pe/v1/tipo-cambio-sunat',
                ['date' => $fecha->format('Y-m-d')]
            );
            if ($resp->successful()) {
                $venta = (float) ($resp->json()['venta'] ?? 0);
                if ($venta > 0) return round($venta, 4);
            }
        } catch (\Throwable $e) {
            Log::warning('TipoCambio fallback', ['e' => $e->getMessage()]);
        }
        return $this->ultimoCacheado() ?? 3.75;
    });
}
// section 07 · testing & ci

$ cat ./testing.md

▸ estrategia

92 tests Pest en suites Feature + Unit. Aislamiento multi-tenant (8 casos en AislamientoTest), ciclo de vida del comprobante (emitir → ENCOLADO → ACEPTADO con FakeSunat, anulacion dentro de los 7 dias, secuencia de correlativo) y contratos de use case (EmitirComprobante valida estado, cuota y detalles; AnularComprobante respeta el plazo). Los tests corren contra una DB MySQL aislada (facturacion_electronica_test), QUEUE_CONNECTION=sync (los jobs corren inline para resultados deterministicos) y SUNAT_FAKE=true. Scripts smoke (smoke-emitir.php, smoke-pdf.php) verifican el flujo completo sin HTTP.

▸ herramientas
Pest 3PHPUnit 11Laravel PintGitHub Actions matrix (PHP 8.3 + MySQL 8.4)
// section 09 · resultados

$ cat ./resultados.md

01 /
6
tipos de comprobante SUNAT (Factura, Boleta, NC, ND, Guia, Comunicacion de Baja)
02 /
32
modelos Eloquent (con traits multi-tenant)
03 /
25
resources Filament (CRUD admin)
04 /
47
endpoints REST (OpenAPI 3.0 + Swagger UI)
05 /
92
tests Pest
06 /
24
docs internas (01-24, arquitectura + integraciones + troubleshooting)
▸ outcomes

Estado de demo tecnica. /health reporta DB + cola OK, /status muestra barras de uptime de 7 dias alimentadas por un cron heartbeat. Los 47 endpoints API funcionan con Sanctum + filtro por global scope. FakeSunat permite demostrar el flujo completo factura → PDF → CDR offline; la integracion real con SUNAT requiere las credenciales OAuth2 SEE del emisor. MercadoPago Checkout Pro esta cableado para el plan Pro (ilimitado) vs Free (100/mes). docs/18-demo-script.md tiene un walkthrough guiado de 15 min.

// section 10 · lecciones

$ cat ./lessons.md

// si lo hiciera de nuevo

  • 01 /

    La cola async es la unica frontera segura contra la latencia de SUNAT

    La primera version esperaba sincrono a SUNAT. En un dia lento cada emision tomaba 30s y la UI parecia rota. Mover a job + enum de estado (ENCOLADO → ACEPTADO/RECHAZADO) hizo la UX instantanea y dejo los reintentos sin intervencion del user.

  • 02 /

    Enums le ganan a las tablas lookup para los catalogos SUNAT

    Detracciones, Retenciones y Percepciones tienen catalogos pequeños y fijos (codigos, porcentajes, umbrales). Meterlos en enums PHP 8 con metodos (porcentaje(), umbralMinimo()) puso la regla al lado del valor, convirtio los malusos en errores de tipo, y elimino una pantalla admin entera.

// relacionados

$ grep -l "tech" ./projects/*

// proyectos que comparten parte del stack

// siguiente paso

$ automata deploy --tu-operacion

// Conversemos sobre como adaptamos esto a tu caso.

./contactar.sh