SUNAT E-Invoicing | Automata
sections (9)
$ cd .. // back to projects
automata@latam: ~/projects/facturador-sunat
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.

Laravel 11Filament 3MySQL 8Greenter 5PHP 8.3PestMercadoPago
SUNAT E-Invoicing
▸ role
Founder · Full-stack · SUNAT integration
▸ team
Solo
▸ status
ready
// section 01 · discovery

$ cat ./discovery.md

▸ overview

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…
// section 04 · infrastructure

$ cat ./infrastructure.md

▸ services
provider: Laravel 11 + Filament 3 + MySQL 8 + Greenter
  • Filament admin panel (Livewire under the hood)
  • MySQL 8 (multi-tenant, per-user RUC scoping)
  • Queue worker (database driver, async SUNAT submission)
  • 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).

app/Models/Scopes/PerteneceAEmpresasDelUsuarioScope.php php
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.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);

        // 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.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 · 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.

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

▸ strategy

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)
// section 09 · results

$ cat ./results.md

01 /
6
SUNAT document types (Factura, Boleta, NC, ND, Guia, Comunicacion de Baja)
02 /
32
Eloquent models (with multi-tenant traits)
03 /
25
Filament resources (admin CRUD)
04 /
47
REST endpoints (OpenAPI 3.0 + Swagger UI)
05 /
92
Pest tests
06 /
24
internal docs (01-24, architecture + integrations + troubleshooting)
▸ outcomes

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.

// related

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

// projects that share part of the stack

// next step

$ automata deploy --your-operation

// Let's talk about adapting this to your case.

./let-s-talk.sh