# vom-zatca

Framework rules come from Laravel Boost: @AGENTS.md. Where this file conflicts with AGENTS.md, **this file wins**.

## What this is
- API-only Laravel 13 / PHP 8.3 service that handles **ZATCA e-invoicing (Fatoora Phase 2)**: EGS onboarding (CSR → compliance CSID → compliance checks → production CSID → renewal) and invoice **reporting** (simplified/B2C) and **clearance** (standard/B2B), plus credit/debit notes.
- **Report only, no calculations.** Clients send fully pre-calculated JSON (line amounts, VAT, discounts, totals, rounding). We validate structure/required fields and map values 1:1 into UBL 2.1. Never compute, recompute, round, or "fix" amounts. ZATCA's validation response is the source of truth for business-rule errors; relay it.
- Sold later as infrastructure-as-a-service. **vom is client #1, not a special case.**

## Client #1 rule
- No vom-specific code, names, branches, or defaults. Never `if ($client->slug === 'vom')`.
- No shared database, no reading vom's DB/files/queues. Communication is only via this API (inbound) and webhooks (outbound).
- `../vom` may be read for domain reference (see "Lessons from vom"). Never edit it.

## Domain & tenancy
- `Client` (tenant, API consumer) → `Taxpayer` (VAT entity: VAT no., CRN, name, national address) → `EgsUnit` (device/branch; owns certificate, private key, CSIDs, ICV counter, PIH chain, ZATCA environment).
- `Document` (invoice / credit note / debit note) belongs to an EgsUnit; `Submission` records each ZATCA call attempt and response.
- Single Postgres database. **Every tenant-owned table has `client_id`** (indexed, FK) and a global scope via a `BelongsToClient` trait that fills and filters it from the authenticated client.
- Route model binding must resolve inside the current client. Another client's ID returns **404**, never 403.
- ICV/PIH: ICV increments per EgsUnit; PIH = base64 SHA-256 hash of the previous signed document of that EgsUnit (first = hash of `"0"`). Assign both inside a DB transaction with `lockForUpdate()` on the EgsUnit row.

## Auth
- Sanctum tokens issued to `Client` (not User). Header: `Authorization: Bearer <key>`.
- Every route declares required abilities, e.g. `taxpayers:read|write`, `egs:onboard`, `documents:read|write`, `webhooks:manage`.
- Keys are hashed at rest, shown once, revocable, and a client can hold multiple keys (rotation).

## API rules
- All routes under `/api/v1` in `routes/api.php`. Breaking changes → new version. No sessions, cookies, CSRF, or Blade.
- Validation only via Form Requests. Output only via API Resources. Never return models or arrays directly.
- Envelope:
  - Success: `{"data": ..., "meta": {...}}`
  - Error: `{"error": {"code": "STRING_CODE", "message": "...", "details": {...}, "request_id": "..."}}`
  - Error codes are a backed enum; validation → `VALIDATION_ERROR` (422) with field errors in `details`. Render all exceptions through the handler in `bootstrap/app.php` in this shape.
- Pagination: page-based (`?page=&per_page=`, default 25, max 100) via `paginate()`. Meta: `current_page, per_page, total, last_page`. No cursor pagination.
- OpenAPI is generated from code with Scramble (typed Form Requests/Resources, docblocks on controllers). No hand-written spec.
- Invoice submit flow: request validates → builds XML → signs → assigns ICV/PIH → returns **201** with document ID, hash, QR, signed XML. ZATCA submission is queued; final status via webhook and `GET /api/v1/documents/{id}`.

## Idempotency & rate limits
- `Idempotency-Key` header is required on every POST. Store key + client_id + request hash + response for 24h.
  - Same key, same body → replay stored response. Same key, different body → 409 `IDEMPOTENCY_CONFLICT`.
- Rate limiting per client (`RateLimiter::for` keyed by client_id). Limits come from client settings, not code.

## Webhooks (outbound)
- Events (`App\Enums\WebhookEvent`): `egs.onboarding.completed`, `egs.onboarding.failed`, `document.reported`, `document.cleared`, `document.rejected`, `document.failed`. Planned: `certificate.expiring`.
- Payload: `{id, type, created_at, data}`. Headers `Webhook-Id`, `Webhook-Timestamp`, `Webhook-Signature: v1=<HMAC-SHA256 of id.timestamp.body>` with the client's webhook secret.
- Delivered from a queued job with exponential backoff, logged per attempt in `webhook_deliveries`. Endpoints and secrets are per client.

## Queues
- Anything external or slow runs in a job on Redis/Horizon: every ZATCA API call, webhook delivery, onboarding.
- Jobs are idempotent and set `tries`, `backoff`, `timeout`, and `failed()` (must leave a terminal status, never stuck "processing").
- HTTP to ZATCA: Laravel `Http` client with connect/request timeouts. 429 and 5xx → retry with backoff; 4xx → terminal failure with ZATCA errors stored.

## Metering
- Write a `usage_events` row (client_id, taxpayer_id, egs_unit_id, event type, reference) for each billable action: document signed, submission, onboarding. Write it in the same transaction as the action.

## Observability
- Middleware sets/propagates `X-Request-Id` and adds `request_id`, `client_id` (and `egs_unit_id` when known) to log context (`Log::withContext` / `Context`). Jobs carry the same context.
- Structured JSON logs. Never log private keys, secrets, tokens, or full invoice XML.
- `GET /api/health` (unauthenticated) checks DB, Redis, and queue.

## Secrets & config over code
- Private keys, CSID secrets, and webhook secrets use `encrypted` casts and are `$hidden`. They never appear in responses.
- Client/taxpayer/EGS-specific behaviour lives in DB settings (JSON column with typed accessors), including the ZATCA environment (sandbox/simulation/production) per EgsUnit, rate limits, and webhook URLs.
- `.env` holds only infrastructure (DB, Redis, app key, ZATCA base URLs per environment).

## Where logic lives
- **Controllers:** thin. Form Request → Action → Resource. No queries or business logic.
- **`app/Actions`:** one class per use case, single `handle()` method (e.g. `SubmitDocument`, `OnboardEgsUnit`).
- **`app/Zatca`:** integration internals behind interfaces: UBL XML builder, signer (native PHP, ext-openssl: XAdES/ECDSA, hashing, QR TLV), CSR generator, API client. No Java SDK, no `shell_exec`.
- **Jobs:** orchestrate Actions/Zatca services only.
- **Models:** relations, casts, scopes. No business logic.

## Conventions
- `declare(strict_types=1);`, `final` classes by default, native backed enums, readonly DTOs, typed params/returns.
- Follow AGENTS.md PHP rules. Run `vendor/bin/pint --dirty --format agent` after PHP changes.
- Migrations: Postgres types (`jsonb`, `uuid`), public IDs are UUIDs, FKs with indexes.

## Testing (PHPUnit)
- PHPUnit class-based tests (`test_snake_case(): void`), matching vom. Create with `php artisan make:test --phpunit`.
- A feature test for every endpoint: happy path, validation, auth/abilities, and a **tenant isolation test** (client A cannot see or modify client B's resources → 404).
- Fake all outbound HTTP with `Http::fake()` and `Http::preventStrayRequests()`. Use `Queue::fake()`/`Bus::fake()` for dispatch assertions.
- Unit tests for signing/hashing/QR against ZATCA sample vectors.
- Use factories for all models.

## Lessons from vom (don't repeat)
- PIH must be the actual previous document hash, not a hash of the counter.
- Never call the compliance-check endpoint during production reporting.
- Store the cleared XML returned by ZATCA and the raw response; use it as the final document.
- Onboarding is async and must never delete client data on failure.
- No global/shared cert files; signing uses the EgsUnit's own key in memory, so it is safe to run concurrently.

## Commands
```sh
composer install && cp .env.example .env && php artisan key:generate
php artisan migrate                        # Postgres (DB_CONNECTION=pgsql)
herd open / php artisan serve              # serve (Herd at http://vom-zatca.test)
php artisan horizon                        # queues
php artisan test --compact [--filter=...]  # tests (Postgres DB vom_zatca_testing)
ZATCA_SANDBOX_TESTS=true php artisan test --compact tests/Integration  # real ZATCA sandbox run
vendor/bin/pint --dirty --format agent     # lint/format
php artisan clients:create "Name" --webhook-url=https://...   # new client (prints webhook secret)
php artisan clients:issue-key <client-id> [--ability=egs:read]  # API key (printed once)
# OpenAPI (Scramble): /docs/api (local only), export with php artisan scramble:export
```
