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.
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…
flowchart TD
owner(((Owner<br/><i>RUC</i>)))
contador(((Contador<br/><i>multi-empresa</i>)))
vendedor(((Vendedor<br/><i>POS / panel</i>)))
erpdev(((ERP dev<br/><i>API REST</i>)))
subgraph sys["Facturador SaaS"]
saas["Emite Factura · Boleta · NC · ND · GRE ·<br/>RHE · Retencion · Percepcion · RC"]
end
cpe["SUNAT CPE<br/><i>SOAP — 01 03 07 08 20 40 RC RA</i>"]
gre["SUNAT GRE<br/><i>REST OAuth2 — tipo 09</i>"]
sole["SUNAT SOLE<br/><i>RHE 02 (roadmap)</i>"]
apisnet["apis.net.pe<br/><i>RUC · DNI · TC SBS</i>"]
owner --> saas
contador --> saas
vendedor --> saas
erpdev -->|Bearer Sanctum| saas
saas -->|XML UBL 2.1 firmado + CDR| cpe
saas -->|OAuth2 + ticket polling| gre
saas -.->|roadmap| sole
saas -->|valida RUC / DNI / TC SBS| apisnet
classDef person fill:#0D1117,stroke:#4ADE80,color:#E5E7EB
classDef system fill:#0D1117,stroke:#06B6D4,color:#E5E7EB
classDef ext fill:#111827,stroke:#6B7280,color:#9CA3AF
class owner,contador,vendedor,erpdev person
class saas system
class cpe,gre,sole,apisnet ext
style sys fill:transparent,stroke:#4ADE80,stroke-dasharray:4 4,color:#E5E7EB
c4 / contenedor · c4-level-2
loading…
flowchart TD
owner(((Owner / Contador / Vendedor)))
erp(((ERP externo<br/><i>Bearer Sanctum</i>)))
subgraph app["Facturador Electronico"]
admin["Admin Panel<br/><i>Filament 3 + Livewire</i><br/>/admin multi-tenant"]
api["API REST v1<br/><i>Laravel 11 + Sanctum</i><br/>47 endpoints /api/v1"]
worker["Queue Worker<br/><i>EnviarComprobante · EnviarBaja ·<br/>EnviarGuiaRemision · ConsultarTicketGre</i>"]
subgraph dom["Capa de Dominio"]
uc["Use Cases<br/><i>EmitirComprobante · AnularComprobante ·<br/>EmitirGuiaRemision · EmitirRHE · ResumenDiario</i>"]
svc["Domain Services<br/><i>CalculadoraTributaria · NumeradorService ·<br/>StockService</i>"]
int["Integration SUNAT<br/><i>SunatService · BajaService · GreService<br/>(+ Fake variants) · DocumentBuilder UBL 2.1</i>"]
end
end
mysql[(MySQL 8.4<br/>empresas · series · comprobantes ·<br/>guias · recibos · retenciones)]
fs[(Storage local<br/>XML firmados · CDR ZIP · certs PFX)]
cpe["SUNAT CPE SOAP<br/><i>sendBill · sendSummary · getStatus</i>"]
gre["SUNAT GRE REST<br/><i>OAuth2 + ticket polling</i>"]
apisnet["apis.net.pe<br/><i>RUC · DNI · TC SBS (cache 24h)</i>"]
owner -->|HTTPS sesion| admin
erp -->|Bearer token| api
admin --> uc
api --> uc
uc --> svc
uc --> int
admin --> mysql
api --> mysql
uc -->|BEGIN COMMIT| mysql
uc --> fs
uc -.->|dispatch| worker
worker --> mysql
worker --> fs
worker --> int
int -->|SOAP UBL 2.1| cpe
int -->|OAuth2 + REST| gre
int -->|HTTPS| apisnet
classDef person fill:#0D1117,stroke:#4ADE80,color:#E5E7EB
classDef container fill:#0D1117,stroke:#06B6D4,color:#E5E7EB
classDef store fill:#111827,stroke:#FBBF24,color:#E5E7EB
classDef ext fill:#111827,stroke:#6B7280,color:#9CA3AF
class owner,erp person
class admin,api,worker,uc,svc,int container
class mysql,fs store
class cpe,gre,apisnet ext
style app fill:transparent,stroke:#4ADE80,stroke-dasharray:4 4,color:#E5E7EB
style dom fill:transparent,stroke:#06B6D4,stroke-dasharray:2 2,color:#E5E7EB
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).
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') );}
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.phpphp
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) ); }}
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)
);
}
}
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.phpphp
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, }; }}
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.
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)
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.
// menciones
$ grep -r "this-url" ./web
// respuestas, reposts y likes desde la web abierta