PRODUCT ·slug: facturador-sunat·2026·5 min read·1,085 words
> SUNAT E-Invoicing
// Multi-tenant SaaS for Peruvian e-invoicing: emits the six SUNAT document types via Greenter with async queue, per-tenant isolation and a public REST API.
A SaaS that lets a Peruvian small business issue Factura (01), Boleta (03), Nota de Credito (07), Nota de Debito (08), Guia de Remision (09) and Comunicacion de Baja (RA) directly to SUNAT, with real XML signing via Greenter. Each user registers their own RUC(s) and tenant data (clients, products, comprobantes) is isolated by a global scope. SUNAT calls run on a queue so the UI returns in <500ms regardless of how long SUNAT takes. A SUNAT_FAKE mode emits real signed XML against a simulated CDR so the whole flow demos offline.
▸ problem
Electronic invoicing in Peru is mandatory but existing SaaS platforms charge 50-400 USD/month per seat. Small businesses pay a tax software vendor instead of focusing on operations, and the few open alternatives need deep integration work.
▸ audience
Peruvian micro/small businesses and freelancers (4ta categoria) with a RUC who need SUNAT-compliant invoicing without paying enterprise SaaS fees.
// section 03 · architecture
$ cat ./architecture.md
c4 / context · 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 / container · 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
▸Scheduler (hourly status-page heartbeat, nightly Comunicacion de Baja batch)
▸MercadoPago Checkout Pro + webhook (signed HMAC SHA-256)
▸Sentry hook + /health endpoint
▸Email queue (PDF + CDR to client on acceptance)
▸VitePress docs site (docs-site/)
▸PHP SDK (sdk/) for the public REST API
// section 05 · implementation
$ cat ./implementation.md
▸ frontend
·Filament 3
·Livewire 3 (via Filament)
·Alpine.js
·Tailwind (via Filament)
·VitePress (public docs)
▸ backend
·Laravel 11
·PHP 8.3
·Greenter 5.2 (XML signing + SOAP)
·spatie/activitylog (audit)
·maatwebsite/excel (bulk import)
·mPDF
·Google2FA (TOTP)
·Sanctum (API tokens)
▸ data
·MySQL 8
·Eloquent + global scopes for tenant isolation
·Cascade + soft deletes for recovery
·String enums for SUNAT codes (CodigoDetraccion, EstadoComprobante, etc.)
▸ devops
·GitHub Actions (Pest + Pint on PHP 8.3 + MySQL 8.4)
·Laragon local dev
·Sentry
// section 06 · technical challenges
$ cat ./challenges/*.md
// 4 technical problems solved
01/04
challenge-01.md · multi-tenant · isolation · idor
▸ problem
Every authenticated user must see only their own comprobantes, clients and products. One missing WHERE leaks data between tenants.
constraint: Global scopes apply automatically to authenticated requests but NOT to CLI commands or queue workers, which legitimately need to operate across all tenants. The bypass must be explicit, auditable and tested.
▸ approach
PerteneceAEmpresasDelUsuarioScope is applied to 12 models. It reads Auth::user() at query time and filters by empresa_id IN (SELECT id FROM empresas WHERE user_id = $userId). Workers and seeders use withoutGlobalScope() explicitly. AislamientoTest.php has 8 cases (user A/B isolation, direct ID lookup, CSV import, unauth bypass).
public function apply(Builder $builder, Model $model): void{ $user = Auth::user(); if (! $user) { return; // CLI / jobs see all — bypass must be explicit elsewhere } $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 see all — bypass must be explicit elsewhere
}
$builder->whereIn(
"{$model->getTable()}.empresa_id",
Empresa::where('user_id', $user->id)->select('id')
);
}
challenge-02.md · async · queue · sunat
▸ problem
SUNAT can take 30s+ to acknowledge a single comprobante. If the web request waits, the UI freezes and users see HTTP 502.
constraint: Cannot lock the comprobante row during submission (deadlock risk, partial-failure visibility). Idempotency matters: a retried job must not double-submit.
▸ approach
EmitirComprobante (the use case) assigns the correlativo, recalculates totals, marks estado = ENCOLADO and returns in <500ms. EnviarComprobanteASunat job runs on the queue, calls SunatService->enviar() and updates estado to ACEPTADO or RECHAZADO. The job is idempotent: if estado is already terminal it skips. tries=3, backoff=30s. SUNAT_FAKE=true swaps the SunatService for one that simulates a valid CDR — the rest of the pipeline (PDF, email, audit) runs unchanged.
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); // Idempotency: skip if already processed 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);
// Idempotency: skip if already processed
if ($comprobante->estado->esEmitido()
|| $comprobante->estado === EstadoComprobante::RECHAZADO) {
return;
}
$sunat->enviar(
comprobante: $comprobante,
document: $builder->build(comprobante: $comprobante)
);
}
}
challenge-03.md · sunat · tax · detracciones
▸ problem
Detracciones (SPOT) are a mandatory tax withholding with 10+ categories, each with its own percentage (4-12%) and minimum invoice threshold (S/ 400 or S/ 700). Get the threshold or percentage wrong and SUNAT rejects the invoice.
constraint: The rule must be automatic (the user shouldnt have to know the catalog) but transparent (it must show on the form so the user can explain it to their client). Thresholds and percentages cannot be hardcoded in multiple places.
▸ approach
CodigoDetraccion is a PHP 8 enum: one case per SUNAT code, with porcentaje() and umbralMinimo() methods. The form shows the Detraccion fieldset only when monto_total >= threshold for the selected code. Tests verify boundary values (S/ 699 = no detraccion, S/ 700 = applies).
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 · external-api · cache · fallback
▸ problem
USD invoices need the official SUNAT exchange rate of the day. apis.net.pe is the public source and it can be slow or down (≈30% failure rate during peak demo day).
constraint: Cannot cache more than one day (rate changes daily). Cannot use a stale rate if today's request fails (audit risk). Cannot block invoice emission if the API is unreachable.
▸ approach
TipoCambioService wraps apis.net.pe with a 5s HTTP timeout. On success: cache for one day under tc:usd:YYYY-MM-DD. On failure: fall back to the most recent cached rate from the last 30 days. Final fallback: 3.75 (safe approximation). The form auto-fills the rate on load and has a manual refresh button.
92 Pest tests in Feature + Unit suites. Multi-tenant isolation (8 cases on AislamientoTest), comprobante lifecycle (emit → ENCOLADO → ACEPTADO with FakeSunat, anulacion within the 7-day window, correlativo sequencing) and use-case contracts (EmitirComprobante validates state, quota and detalles; AnularComprobante respects deadlines). Tests run on an isolated MySQL DB (facturacion_electronica_test), QUEUE_CONNECTION=sync (jobs run inline for deterministic outcomes) and SUNAT_FAKE=true. Smoke scripts (smoke-emitir.php, smoke-pdf.php) verify the full flow without HTTP.
▸ tools
Pest 3PHPUnit 11Laravel PintGitHub Actions matrix (PHP 8.3 + MySQL 8.4)
Technical demo state. /health reports DB + queue OK, /status shows 7-day uptime bars driven by a cron heartbeat. All 47 API endpoints work with Sanctum + global-scope filtering. FakeSunat lets the full invoice → PDF → CDR flow demo offline; the live SUNAT integration needs the producer's SEE OAuth2 credentials. MercadoPago Checkout Pro is wired for the Pro plan (unlimited) vs Free (100/month). docs/18-demo-script.md has a 15-min guided walkthrough.
// section 10 · lessons learned
$ cat ./lessons.md
// if I did it again
01 /
Async queue is the only safe boundary against SUNAT latency
The first version waited synchronously for SUNAT. On a slow day every emit took 30s and the UI looked broken. Moving to a job + status enum (ENCOLADO → ACEPTADO/RECHAZADO) made the UX feel instant and let retries happen without user intervention.
02 /
Enums beat lookup tables for SUNAT catalogs
Detracciones, Retenciones and Percepciones each have a small fixed catalog (codes, percentages, thresholds). Putting them in PHP 8 enums with methods (porcentaje(), umbralMinimo()) put the rules next to the value, made misuses a type error, and removed an entire admin screen.