# CLAUDE.md

> Este archivo le da contexto permanente a Claude Code en cada sesión de trabajo sobre Innovium.
> **No borrar.** Actualizar cuando se tomen decisiones importantes.

## Proyecto

**Innovium** — SaaS multi-tenant de gestión funeraria.
**Empresa:** Crono Systems · Santiago, Chile.
**Owner:** Ricci Maturana (`ricci2diaz` en GitHub).
**Dominio:** `innovium.cl` (confirmado, marca INAPI registrada).

## Estado actual

- ✅ Diseño visual completo y cerrado (ver `design-kit/`)
- ✅ Arquitectura multi-tenant decidida
- ✅ Stack tecnológico decidido
- ✅ **Fase 0 completada** (Abril 2026)
  - Estructura de carpetas
  - Composer + npm con todas las dependencias
  - Tailwind + design-kit compilando correctamente
  - Core mínimo: Container, Router, Request, Response, View, Database, Config
  - CLI funcional: serve, key:generate, make:migration
  - Welcome page validada visualmente
  - 9 commits atómicos en main
- ✅ **Sprint 1.1 completado — auth + RBAC + audit_log** (Abril–Mayo 2026)
  - 8 tablas del schema de sistema en `innovium_demo`: users, roles,
    permissions, role_permissions, user_roles, sessions, password_resets,
    audit_log. Migrations versionadas con tracking + hash check (Migrator).
  - 6 roles + 25 permisos granulares + 6 usuarios demo seedeados,
    passwords con Argon2id (m=65536, t=4, p=1).
  - `App\Core\Auth` (sesiones en BD con sliding 8h+4h o cap 30d Recordarme),
    `App\Core\Csrf` (signed double-submit cookie con TTL 24h y APP_KEY ≥32),
    `App\Core\AuditLog` (record con JSON datos_antes/despues).
  - 3 middleware (`auth`, `csrf`, `rbac:slug.foo`) sobre pipeline del Router.
  - 4 data-mappers livianos: User, Role, Permission, Session (PDO directo,
    sin ORM, sin lazy loading).
  - 2 controllers: AuthController (showLogin/login/logout con anti-enumeration
    y open-redirect protection) y DashboardController (placeholder).
  - Vistas con design-kit: login replicando `v2_login.svg`, layout
    autenticado y dashboard placeholder con sidebar mínimo, greeting
    según hora chilena, info cards y roadmap.
  - 29 tests PHPUnit (68 assertions) cubriendo Auth, Csrf, Rbac y AuditLog
    con aislamiento transaccional.
  - 16 commits atómicos en main siguiendo el plan + cleanups (CSRF hardening
    con timestamp, routes wire-up, .env doc localhost vs 127.0.0.1, refactor
    Router para soportar middleware).
- ✅ **Sprint 1.2 completado — multi-tenancy core** (Mayo 2026)
  - 5 tablas en `innovium_master`: tenants, tenant_features,
    tenant_billing, tenant_admin_users, tenant_audit_log. Migrations
    versionadas con tracking + hash check (Migrator reusado).
  - `App\Core\TenantResolver`: parsea HTTP_HOST, valida slug con
    regex `^[a-z][a-z0-9-]{2,30}$` + 10 reservados (admin/api/www/
    master/innovium/etc) ANTES de tocar BD, lookup en master con
    prepared statement, 404 genérico (no revela reservado vs
    inexistente), 503 si suspendido.
  - `App\Core\Tenant`: value object readonly con id/slug/nombre/
    db_name/storage_path/estado/branding/features. Inyectado en el
    Container ('tenant' + clase) por el front controller.
  - `App\Core\TenantResolutionException`: tipada con httpStatus
    (404 o 503) y userMessage seguro.
  - `View::render` auto-inyecta `$tenant` desde el Container — el
    badge de login y dashboard ahora muestra "Innovium · &lt;Tenant&gt;"
    + hostname dinámicos según subdominio.
  - 5 comandos CLI nuevos: `master:migrate`, `db:seed-master`,
    `db:create-infinia`, `db:seed-infinia`, `tenant:create &lt;slug&gt;
    --name --short [--features]`.
  - `tenant:create` end-to-end: regex + reservados + pre-flight,
    CREATE DATABASE, apply migrations, seed RBAC + 1 admin con
    password aleatoria 16 chars, INSERT en master.tenants +
    tenant_features + tenant_billing + tenant_audit_log. Rollback
    granular si falla a mitad (DROP DATABASE + DELETE en master).
  - Refactor menor: RBAC baseline (6 roles + 25 permisos + mapeo)
    extraído a `database/seeds/data/rbac_baseline.php` para ser
    reusado por DemoTenantSeeder, NewTenantSeeder e InfiniaTenantSeeder.
  - 2 tenants seedeados en master: `demo` (3 features) e `infinia`
    (4 features incluyendo acompanamiento_duelo). Infinia con 6
    usuarios sintéticos chilenos distintos a demo (`@infinia.cl`).
  - 5 tests de aislamiento en tests/integration/TenantIsolationTest.php.
    Suite completa: 34 tests / 119 assertions, todo verde.
  - 9 commits atómicos en main siguiendo el plan del prompt.
- ✅ **Sprint 1.3 completado — schema del catálogo** (Mayo 2026)
  - 9 migraciones tenant (0009-0017) creando 8 tablas nuevas:
    `categorias`, `niveles`, `productos`, `producto_variantes`,
    `producto_imagenes`, `planes_lineas`, `precios_uf`, `tenant_config`.
    La 0017 es un ALTER que cierra el FK circular productos↔
    producto_imagenes (resuelto en 3 pasos).
  - 11 FKs aplicadas con políticas DELETE_RULE específicas: RESTRICT
    para categoria/nivel/producto-en-plan (no podés borrar lo
    referenciado), CASCADE para variantes/imágenes (mueren con el
    padre), SET NULL para users.id (preserva trazabilidad histórica).
  - 8 Models en `app/Models/` siguiendo el patrón data-mapper de
    `User.php`: FILLABLE whitelist privada, find/all/create/update/
    softDelete, `Database::current()` para PDO, sin lazy loading,
    sin escribir audit_log (excepto TenantConfig::set, ver más abajo).
  - Lookups específicos del dominio: `Producto::findByCodigo`,
    `findByCategoria`, `findVendiblesParaPlan(catId?, nivelId?)`,
    `findVendiblesComoAdicional`, `findPlanes(disponiblePara)`;
    `PlanLinea::findByPlan`; `PrecioUf::getValorActual` con cascada
    (precios_uf → tenant_config.valor_uf_actual → RuntimeException).
  - **Excepción al "models silenciosos":** `TenantConfig::set()`
    escribe audit_log con acción `tenant_config.actualizado`.
    Guarda CLAVE+TIPO, NO el VALOR (futuras configs pueden ser
    sensibles: tokens, credenciales).
  - `TenantConfig` casting automático por tipo ENUM (string/int/
    decimal/boolean/json/date). Helper `seedIfMissing()` idempotente
    para seeds iniciales.
  - Refactor menor de DemoTenantSeeder, NewTenantSeeder e
    InfiniaTenantSeeder NO requerido — el RBAC baseline ya estaba
    extraído en Sprint 1.2.
  - `CatalogoBaselineSeeder.php` siembra 3 settings en `tenant_config`
    (valor_uf_actual=38500, iva_porcentaje=19, moneda_default=CLP)
    y 1 fila en `precios_uf` (fecha=hoy, valor=38500). Idempotente.
  - Comando CLI nuevo: `db:seed-catalogo <slug>`.
  - Migrations + seeds aplicados a `innovium_demo` e `innovium_infinia`.
    Tablas de catálogo (categorias/niveles/productos/etc) quedan
    vacías intencionalmente: el admin de cada funeraria carga sus
    productos en Sprint 1.4 vía UI.
  - 51 tests unit nuevos (CategoriaTest 8, NivelTest 5, ProductoTest 10,
    PlanLineaTest 5, TenantConfigTest 12, PrecioUfTest 11).
    Suite completa: **85 tests / 272 assertions, 100% verde**.
  - 12 commits atómicos en main siguiendo el plan del prompt
    (commit 12 del plan se fusionó con este cierre porque no
    requería cambios al repo, solo ejecución de comandos).
- ✅ **Sprint 1.4 completado — panel administrativo** (Mayo 2026)
  - 3 piezas de infraestructura nuevas en `app/Core/`:
    `Validator` (~600 líneas) con reglas built-in + chilenas (`rut`,
    `telefono_chileno`, `slug`, `precio_clp/uf`, `unique`/`exists`
    con BD + `dimensions`); `Storage` gateway R2 con prefijo
    automático `/tenants/<slug>/` y URLs firmadas; `Upload` +
    `UploadedFile` value object con cascada estricta de validaciones
    (mime detectado por magic bytes, no por header del cliente,
    UUIDs de 32 chars hex en R2).
  - 8 permissions nuevos seedeados: `admin.acceso`, `categorias.
    gestionar/ver`, `niveles.gestionar/ver`, `productos.ver` (la
    `productos.gestionar` ya existía), `planes.gestionar/ver`.
    Mapping: `tenant_admin` → todo (`*`); `gerente` → modo solo-lectura
    (`admin.acceso` + `*.ver`); `vendedor`/`operativo`/`contador`
    sin acceso al panel.
  - Nuevo layout `app/Views/layouts/admin.php` con sidebar 240px
    (logo + tenant chip + MÓDULOS placeholders + sección
    ADMINISTRACIÓN dinámica según permisos + user card al fondo) +
    topbar con greeting/breadcrumbs/CTA contextual + drawer mobile
    via Alpine. 19 iconos Lucide SVG inline (`app/Views/components/
    icon.php`).
  - 11 helpers de UI en `app/Views/components/components.php`:
    `boton`, `pill`, `tabla`, `modal`, `confirm_dialog`,
    `toast_container`, `form_input/select/textarea/toggle/
    image_upload`. Singletons `confirm_dialog` y `toast_container`
    incluidos automáticamente en el layout.
  - JS modular en `resources/js/innovium-admin.js` (también copiado
    a `public/assets/js/`): helpers globales `innoviumCsrfToken`,
    `innoviumFetch`, `innoviumValidarRUT`, `innoviumToast`,
    `innoviumConfirm`; Alpine components `imageUploader` (drag&drop
    + preview + AJAX) y `rutInput` (validación inline debounced).
  - 5 controllers en `app/Controllers/Admin/` (~1500 líneas total)
    con CRUD JSON-API: `AdminCategoriaController`,
    `AdminNivelController`, `AdminProductoController` (+ uploadImagen,
    eliminarImagen, marcarPrincipal), `AdminPlanController`
    (+ lineasIndex, lineaCrear, lineaEliminar),
    `AdminConfiguracionController` (saveMonetario, saveFuneraria,
    uploadLogo).
  - 5 vistas en `app/Views/admin/<entidad>/index.php` (~3500 líneas
    total) con tablas Alpine reactivas + búsqueda/filtros inline
    cliente + modales reactivos compartidos crear/editar.
    `/admin/configuracion` usa tabs Linear-style en vez de modal
    (4 tabs: Monetario, Funeraria, Branding, Avanzado).
    `/admin/planes` tiene builder de líneas con 3 modos
    (`producto_fijo`, `eleccion_categoria`, `eleccion_nivel`) y
    sub-modales contextuales para agregar.
  - 24 routes nuevos en `public/index.php`, todos con
    `auth + csrf + rbac:<permiso>`. Patrón: GET para listado con
    `rbac:admin.acceso` (gerente entra), POST para mutaciones con
    `rbac:<entidad>.gestionar` (solo tenant_admin).
  - Audit log: 9 acciones distintas (`categoria.*`, `nivel.*`,
    `producto.*`, `plan.*`, `plan_linea.*`,
    `tenant_branding.logo_actualizado`) además del
    `tenant_config.actualizado` automático del Sprint 1.3.
  - 3 archivos de tests unit (`ValidatorTest` 28 tests, `UploadTest`
    10 tests, `StorageR2Test` 7 tests vía reflection) y 2 archivos
    integration (`AdminAccessTest` 15 tests con matriz de access
    control para los 5 controllers, `AdminCategoriaControllerTest`
    9 tests del flujo CRUD + audit_log + validation errors).
  - Suite final: **159 tests / 475 assertions, 100% verde**
    (85 previos del Sprint 1.1+1.2+1.3 + 50 unit nuevos + 24
    integration nuevos).
  - 15 commits atómicos en main siguiendo el plan del prompt.
- ✅ **Sprint 1.5a completado — módulo de Contratos NI** (Mayo 2026)
  - 16 tablas nuevas en `innovium_<tenant>`: `entidades_previsionales`,
    `parentescos`, `estados_civiles`, `nacionalidades`, `regiones`,
    `provincias`, `comunas`, `capillas`, `tipos_carroza`, `convenios`,
    `sucursales`, `velatorios`, `clientes`, `fallecidos`,
    `contratos`, `contratos_secuencias`, `contrato_aportes_previsionales`,
    `contrato_servicios_extra`. Migrations 0018-0033 con tracking.
  - Catálogos chilenos seedeados: ~30 entidades previsionales (AFPs/
    seguros/IPS/mutuales con orden_visual), 19 parentescos, 5 estados
    civiles, 160 nacionalidades (CHILENA primero), 16 regiones / 56
    provincias / 346 comunas, 3 capillas, 4 tipos de carroza.
    Comando `catalogos:seed <slug>` idempotente para tenants existentes.
  - 18 Models data-mapper nuevos: 10 read-mostly de catálogos
    (`EntidadPrevisional`, `Parentesco`, etc.), `Cliente` con
    `normalizeRut` canónico + `findByRut` + `searchByRutOrName`,
    `Fallecido`, `Contrato` con `generarNumero(tipo)` ATÓMICO via
    `SELECT FOR UPDATE` (requiere transacción del caller),
    `AporteContrato::bulkCreate` con MAX_POR_CONTRATO=3,
    `ServicioContrato::bulkCreate` sin máximo, `Sucursal`,
    `Velatorio`, `Convenio`.
  - `App\Helpers\Formatters` chilenos (`rut`, `clp`, `uf`, `fecha`,
    `fechaHora`) con métodos estáticos + funciones globales.
  - 3 CRUDs admin nuevos: `/admin/sucursales`, `/admin/velatorios`,
    `/admin/convenios` (mismo patrón que Sprint 1.4 con modal Alpine
    + audit_log + permissions granulares `.gestionar`/`.ver`).
  - `/admin/contratos` listado con filtros (estado, tipo, RUT,
    fecha desde/hasta) y `/admin/contratos/ver?id=N` detalle 2-col.
  - **Wizard de venta NI de 7 pasos** (`WizardNIController`):
    cliente con búsqueda RUT debounced (paso 1), fallecido con
    validación cruzada de fechas (paso 2), plan + cofre + medidas
    (paso 3), servicio funerario con 8 toggles de items (paso 4),
    complemento con toggle "por confirmar" para fecha (paso 5),
    pagos con aportes max 3 + servicios extra ilimitados + convenio
    + descuento + abono y resumen calculado en vivo (paso 6),
    firma + creación TRANSACCIONAL completa con captura HTML5
    canvas mouse+touch + upload firma a R2 post-commit (paso 7).
    Estado en `App\Helpers\WizardSession` ($_SESSION nativo).
  - `App\Services\PdfGeneratorNI` con DomPDF 3.1: tamaño Carta
    chileno 216×330mm, marca de agua del número en background,
    template idéntico al legacy de Infinia con 8 secciones +
    resumen económico + texto legal + firma incrustada vía data
    URL embebida (`Storage::get` + base64). Endpoint
    `/admin/contratos/pdf?id=N&download=1&regenerar=1` con cache
    R2 lazy. Botones "Ver PDF", "Descargar PDF" y "Enviar por
    email" (mailto: pre-llenado, SMTP integrado en Sprint 1.5b).
  - `Storage::get(path)` nuevo método para descargar bytes desde R2
    (necesario para embedados server-side en PDFs).
  - **40 tests nuevos**: ClienteTest (11), FallecidoTest (5),
    ContratoTest (6), AporteServicioContratoTest (4),
    WizardNIControllerTest (10 integration · cubre el flow paso a
    paso + creación transaccional completa con cleanup manual del
    test que sale de transacción), PdfGeneratorNITest (4 con
    verificación de magic bytes %PDF/%%EOF).
  - Suite final: **199 tests / 597 assertions, 100% verde**.
  - 32 commits atómicos en main siguiendo el plan del prompt.
- ✅ **Sprint 1.5a-fixes completado — 10 bugs corregidos** (Mayo 2026)
  - Sub-sprint de mantenimiento sin features nuevas, post-validación
    visual del Sprint 1.5a por Ricci.
  - **Bug #2 (causa raíz):** migration 0034 agrega 5 columnas
    nullable a `productos` (cofre_codigo/color/alto/ancho/largo).
    Form `/admin/productos` con sección "Medidas del cofre"
    visible solo para tipo IN ('producto', 'plan').
  - **Bug #1:** wizard paso 3 muestra los datos del cofre como
    text plano READ-ONLY cuando hay un plan seleccionado;
    backend `guardarPlan` IGNORA los cofre_* del request si
    plan_id está seteado y re-hidrata desde el catálogo
    (defensa contra override).
  - **Bug #10 (BLOQUEANTE):** convenio_id=0/''/null aceptado
    como "sin convenio" en wizard paso 6. Solo valida si > 0.
  - **Bug #3 + #9:** componente Alpine `moneyInput` reutilizable
    con auto-formato es-CL (1.200.000), bloqueo de ceros a la
    izquierda, vacío en blur si raw=0. Aplicado a precio_base_clp
    en `/admin/productos` y a 4 inputs del paso 6 (servicios
    monto, aportes monto, descuento_clp, abono_cliente_clp).
  - **Bug #4:** wizard paso 1 muestra form COMPLETO editable
    cuando hay cliente seleccionado (no card read-only). Backend
    valida campos, hace UPDATE solo si difieren del original
    (helper `diffClienteDatos`), audit_log condicional.
  - **Bug #6:** los 8 toggles del paso 4 arrancan PRE-marcados
    en `WizardSession::initialState()` (cruz, tarjetero, libro/
    tarjeta condolencias, arreglo floral, cafetería, trámite
    Reg. Civil, certificación médica) — reduce 8 clicks por venta.
  - **Bug #7:** layout aportes previsionales responsive — grid
    horizontal en md+ (entidad 5 / monto 3 / fecha 3 / quitar 1),
    apilado vertical con labels eyebrow en mobile.
  - **Bug #5:** placeholders del color del cofre clarificados
    (wizard manual: "Ej: Blanco" + hint "Un solo color"; admin
    productos: hint "Color principal").
  - **Bug #8:** confirmado, sin cambios (descuento editable +
    cofre read-only es comportamiento correcto).
  - **9 tests integration nuevos** en `Sprint15aFixesTest.php`
    cubriendo bugs #1, #2, #4, #6, #10 (incluyendo defensa
    contra override del cliente, no-op cuando no hay cambios,
    y validación legítima preservada).
  - Suite final: **208 tests / 643 assertions, 100% verde**
    (199 previos + 9 nuevos del sub-sprint).
  - 15 commits atómicos en main siguiendo el plan del prompt.
- ✅ **Sprint 1.5a-fixes-2 completado — PDF replicado del legacy + 5 fixes UX/datos** (Mayo 2026)
  - Sub-sprint de mantenimiento post-validación visual del Sprint
    1.5a-fixes (10 bugs previos). 6 bugs nuevos detectados, 6
    arreglados. El mayor: el PDF generado por DomPDF NO replicaba
    el PDF legacy de Infinia.
  - **Bug #17 (CRÍTICO · 4 commits — REVERTIDOS, ver sprint pdf-replica
    abajo):** REEMPLAZO completo de DomPDF por FPDF (`setasign/fpdf`
    1.8.6) con replicación 1:1 del código `LEGACY_GENERARPDF.php`
    (945 líneas). Nueva clase
    `App\Services\Pdf\PDF` extends FPDF con helpers idénticos al
    legacy: `Header()` (banda gris + tarjeta con N° contrato),
    `Footer()` (firma incrustada + bloque firmas EJECUTIVO/
    CONTRATANTE + paginado + caja de notas legales + marca de
    agua del logo), `RoundedRect()`+`_Arc()` (FPDF no los trae
    nativo), `SectionTitle()`, `CardStart()`. Orquestador
    `App\Services\PdfGeneratorNI` con las 5 secciones del legacy
    (CONTRATANTE, FALLECIDO, SERVICIO FUNERARIO con MEDIDAS DEL
    COFRE + OTROS SERVICIOS, COMPLEMENTO, RESUMEN DE VALORES con
    columna izq de valores y derecha con APORTE PREVISIONAL
    detallado vía `Ln(-31)`+`Cell(115)`). Mantiene firma pública
    (constructor `(int $contratoId, bool $compressed = true)` +
    `generar(): string`) — el flag `compressed` desactiva
    compresión FPDF para que tests aserten strings de secciones
    en plain bytes. Datos vienen via propiedades públicas
    (no queries DB como el legacy). Carta chileno 216×330mm,
    `utf8_decode()` en todos los strings (FPDF Latin1 nativo),
    isRemoteEnabled equiv (Image solo paths locales · firma
    descargada de R2 a `tempnam()` y limpiada en `finally{}`).
    5 tests en `PdfGeneratorNITest` cubriendo magic bytes
    `%PDF-`/`%%EOF`, presencia de las 5 secciones por título,
    edge case sin fallecido. Docs de referencia en
    `docs/sprint-1-5a-fixes-2/LEGACY_GENERARPDF.php` +
    `PDF_INFINIA_REFERENCIA.pdf`.
  - **Bug #13:** `_toRow()` JS del modal `/admin/productos`
    propaga ahora los 5 campos cofre_* (codigo/color/alto/
    ancho/largo). El server SÍ persistía desde Sprint 1.5a-fixes
    commits 2+3 — el bug era visual: al re-abrir el modal de
    edición, los inputs aparecían vacíos porque `page.rows[idx]`
    no tenía esos campos. Test
    `test_bug13_producto_update_persiste_medidas_cofre` en
    `Sprint15aFixesTest`.
  - **Bug #16:** `app/Views/components/admin/topbar.php` ahora
    emite `$titulo` raw (sin `View::e()`) porque el contenido
    de `View::section('titulo')` viene del template del
    developer (HTML válido) y los datos dinámicos ya están
    escapados dentro del section. Patrón Laravel-style. No hay
    riesgo XSS · `$titulo` nunca viene de user input.
  - **Bug #11:** Modal "Nuevo plan" en `/admin/planes` ahora usa
    el patrón `moneyInput` inline (mismo del Sprint 1.5a-fixes
    commit 8 para `/admin/productos`): `:value` Intl es-CL,
    `@input` `window.innoviumParseMoney`, `@blur` vacío si raw=0.
  - **Bug #14:** Wizard paso 3 ahora oculta la fila "Medidas
    (alto×ancho×largo): —" cuando el plan no tiene medidas en
    BD. En su lugar muestra warning amber accionable: "Este
    plan no tiene medidas del cofre cargadas. Editalo en
    /admin/productos para que el contrato y el PDF las
    incluyan." Link abre nueva pestaña.
  - **Bug #15:** Migration 0035 + ALTER ENUM agrega 'vigente'
    + UPDATE rows con estado='firmado' → 'vigente' + cambia
    DEFAULT. WizardNIController::guardar escribe 'vigente'.
    Vistas (listado + detalle) muestran badge cyan-soft para
    vigente (NO verde — sugiere finalizado). Filtro dropdown
    actualizado. 'firmado' queda en ENUM como backwards-compat
    hasta Sprint 2.x.
  - **Bug #12:** Dropdown TIPO en `/admin/productos` ahora
    muestra "Producto · item físico individual" / "Servicio ·
    prestación, no se entrega" / "Plan · paquete pre-armado".
    Helper text debajo con ejemplos.
  - Composer: `setasign/fpdf` ^1.8 (DomPDF + dompdf/php-svg-lib +
    dompdf/php-font-lib removidos).
  - Suite final: **210 tests / 658 assertions, 100% verde**
    (DomPDF tests del Sprint 1.5a eliminados, FPDF tests
    re-introducidos · neto +1 test). Las "Deprecations: 97" son
    del código clásico de FPDF (PHP 8.x notices) y no bloquean.
  - 11 commits atómicos en main (4 de Fase A · PDF + 1 de B + 1
    de C + 4 de D + 1 cierre).
- ✅ **Sprint pdf-replica completado — transcripción real del PDF legacy** (Mayo 2026)
  - Re-iteración del Bug #17 del Sprint 1.5a-fixes-2. Los 4 commits
    originales (`b7a8314 chore(deps): swap…` → `f02a277 test(pdf):…`)
    fueron **revertidos por Ricci** porque el código generado había
    inventado diseño "moderno" en lugar de copiar el legacy: nombres
    de sección renombrados ("CONTRATANTE" en vez de "1. DATOS DEL
    CONTRATANTE"), texto legal redactado libremente, marca de agua
    como texto del número, header con "DEMO/Servicios funerarios/
    Sucursal: Casa Matriz", footer "Documento generado por…", sección
    5 sin las 2 columnas exactas, etc.
  - Re-arranque con el prompt como TRANSCRIPTOR: copiar del legacy
    `docs/paquete-pdf-replica/LEGACY_GENERARPDF.php` línea por línea,
    cero diseño propio, cero refactor. 10 prohibiciones absolutas +
    9 ítems del checklist DoD que el modelo recorre antes de pedir
    validación visual.
  - 4 commits en main:
    1. `chore(deps): swap dompdf por fpdf · stub temporal del PDF`
       — composer.json: -dompdf/dompdf -mpdf/mpdf +setasign/fpdf
       ^1.8. PdfGeneratorNI queda como stub que tira RuntimeException
       hasta el commit 2 (preserva firma pública para no romper
       autoload del controller).
    2. `feat(pdf): PdfGeneratorNI con FPDF transcribiendo legacy 1:1`
       — clase `App\Services\Pdf\PDF` extends FPDF con los 6 helpers
       visuales del legacy (SectionTitle, CardStart, RoundedRect,
       _Arc, Header, Footer) + orquestador `App\Services\PdfGeneratorNI`
       (~600 líneas) con las 5 secciones transcritas con coordenadas
       y anchos EXACTOS del legacy. Firma pública compatible
       `(int $contratoId, bool $compressed = true)` + `generar(): string`.
       Datos vienen via propiedades públicas inyectadas (no queries
       directas como el legacy). Marca de agua = `Image()` del logo
       del tenant cargado en `tenant_config.logo_r2_path` (no texto
       fallback si no hay logo). Firma del cliente descargada de R2
       a tempfile via `tempnam()`, limpiada en `finally{}`. Edge
       cases hardcoded del legacy por contrato/plan ID (303, 18926462,
       63/64/70) NO transcritos — eran reglas puntuales de Infinia,
       no semántica universal.
    3. `test: PDF tiene secciones y notas legales correctas`
       — 10 tests / 37 assertions: magic bytes %PDF-/%%EOF, /Count 1
       en /Type /Pages (1 sola página), 5 títulos exactos, 5 notas
       legales literales (substrings ASCII únicos), footer con
       "Page 1/", "EJECUTIVO:", "CONTRATANTE:", "NOTAS:", asserts
       NEGATIVOS de strings prohibidos del diseño previo
       ("Documento generado por", "Casa Matriz", "Servicios
       funerarios", "RESUMEN ECONOMICO", "FUNERAL Y LUGARES",
       "Sucursal:"), aportes + servicios extra renderizados, edge
       case sin fallecido. Tests usan `compressed: false` para que
       los strings estén en plain bytes; producción siempre con
       compresión ON.
    4. `chore: sprint pdf-replica cerrado` (este).
  - Auditoría manual de 29 sub-checks del DoD: 29/29 ✓.
    PDF de smoke en `storage/tmp/contrato_smoke_<numero>.pdf` para
    comparación visual lado a lado con
    `docs/paquete-pdf-replica/PDF_INFINIA_REFERENCIA.pdf`.
  - **Límite documentado:** el layout transcribido soporta el caso
    típico (1-2 aportes previsionales + 0-1 servicios extra), que
    es el escenario del PDF de referencia. Stress data (3 aportes
    + 2+ servicios) puede saltar a página 2 — se trata aparte en
    Sprint 2.x si surge demanda real.
  - **Logo del tenant para watermark:** asume PNG pre-procesado
    con opacidad ya aplicada (FPDF clásico no trae `SetAlpha` sin
    extensión externa). Si Innovium en el futuro necesita aplicar
    opacidad server-side, evaluar `setasign/fpdi` o branch a
    TCPDF para esa pantalla específica.
  - Suite final: **215 tests / 679 assertions, 100% verde**
    (205 previos + 10 nuevos). Deprecations: 100 (utf8_decode de
    FPDF clásico, PHP 8.x notices, no bloquean).
- ✅ **Sprint 1.5a-fixes-3 completado — cierre de Sprint 1.5a** (Mayo 2026)
  - Sub-sprint final de fixes post-validación visual. 5 bugs
    encontrados, 4 con cambios de código (1 ya estaba arreglado).
  - **Bug #36 · GRAVE:** sidebar mostraba ADMINISTRACIÓN solo cuando
    la URL empezaba con `/admin/*`. El `tenant_admin` que entraba a
    `/dashboard` quedaba atrapado sin poder navegar al panel admin.
    Causa: el dashboard view usaba `layouts.app` con un sidebar
    inline propio que solo listaba MÓDULOS placeholders. Fix:
    refactor `dashboard/index.php` para extender `layouts.admin`,
    que ya tiene `components/admin/sidebar.php` con la sección
    ADMINISTRACIÓN renderizada condicionalmente según
    `Auth::can('admin.acceso')`. El hero greeting pasa al topbar via
    `View::section('titulo')`; cards y roadmap quedan en `content`.
  - **Bug #38 · GRAVE:** Contratos estaba duplicado en el sidebar
    (PRÓX. en MÓDULOS y funcional en ADMINISTRACIÓN). Fix: ahora
    vive solo en MÓDULOS como ítem funcional accesible para los 5
    roles operativos. Nuevo permiso `contratos.acceso` mapeado a
    `tenant_admin` (vía `*`), `gerente`, `vendedor`, `contador`,
    `operativo`. Re-seed idempotente en demo + infinia (41 permisos
    / 127 role_permissions). Rutas `/admin/contratos*`,
    `/admin/contratos/ver`, `/admin/contratos/pdf` cambian middleware
    de `rbac:admin.acceso` a `rbac:contratos.acceso`. URL no cambia,
    solo el lugar en sidebar y los roles permitidos. Sidebar ahora
    soporta items de MÓDULOS funcionales (con `href` + `permiso`
    opcional) además de placeholders PRÓX.
  - **Bug #37 · MEDIO:** `/admin` y `/admin/` devolvían 404 genérico.
    Ahora redirige 302 a `/admin/contratos` (si tiene
    `contratos.acceso`) o `/dashboard` (si solo tiene login). Detrás
    del middleware `auth` para no exponer estructura del panel a
    anónimos.
  - **Posición vertical de la firma del contratante:** legacy usaba
    `Image(..., 135, GetY()-2, 60, 0, 'PNG')` lo cual asumía que el
    contenido siempre llegaba al borde inferior. En Innovium el
    contenido es variable (secciones opcionales del wizard NI) y la
    firma flotaba muy arriba del bloque CONTRATANTE. Fix: posición
    absoluta a `$this->h - 55` (275mm en página Carta de 330mm),
    determinista regardless del fill del contenido.
  - **utf8_decode → PDF::tx con iconv:** ya estaba arreglado en
    commit `51f74c1` (Sprint 1.5a-fixes-2 cleanup). 0 deprecations
    en suite de PDF. Sin cambios de código en este sprint para ese
    bug, solo verificación.
  - Suite final: **224 tests / 704 assertions, 100% verde**
    (215 previos del Sprint 1.5a-fixes-2 sin nuevos tests en este
    sub-sprint — los fixes son visuales/UX y la cobertura existente
    ya cubre el comportamiento de auth/rbac/redirect).
  - 4 commits atómicos en main (#36 → #38 → #37 → firma) + cierre.
- 🔜 **Sprint 1.5b pendiente:** módulo NF (Necesidad Futura) +
  cobranzas + SMTP integrado.
- ⏳ Migración del legacy pendiente

## Arquitectura — decisiones canónicas

- **Multi-tenancy Modelo A:** UNA BD por funeraria (`innovium_<slug>`) + BD maestra (`innovium_master`). Aislamiento físico de datos. NO compartir tablas entre tenants.
- **Resolución de tenant:** por subdominio (`acoger.innovium.cl` → tenant `acoger`). Wildcard SSL `*.innovium.cl`.
- **Storage:** Cloudflare R2, UN bucket `innovium-prod-storage`, segregación por path `/tenants/{slug}/...`. URLs firmadas con expiración 5 min.
- **Front controller único:** `public/index.php`, todas las requests pasan por ahí, el `TenantResolver` decide qué BD usar.
- **Hosting:** VPS HostGator reseller con WHM/cPanel.

## Stack

| Capa | Tecnología | Versión |
|---|---|---|
| Backend | PHP | 8.2+ |
| BD | MySQL / MariaDB | 8 / 10.6+ |
| Framework | MVC custom (sin Laravel/Symfony) | propio |
| Acceso a BD | PDO con prepared statements | obligatorio |
| Frontend interactivo | Vanilla JS + Alpine.js | 3.x |
| CSS | Tailwind CSS | 3.x |
| Iconos | Lucide Icons | última |
| Charts | ApexCharts | última |
| PWA | Service Worker + IndexedDB | nativo |
| Storage | Cloudflare R2 (S3-compatible) | aws-sdk-php |
| PDF | mPDF | 8.x |
| Hash de passwords | Argon2id | nativo PHP |
| Mobile companion | React Native + Expo | repo separado |

## Reglas de oro (no negociables)

1. **PDO prepared statements siempre.** Cero concatenación SQL, cero `mysql_query`. El legacy tenía SQL injection — no repetir.
2. **Validación server-side OBLIGATORIA.** El cliente puede mentir. El legacy tenía `var validado = true; //jesus` — eso queda prohibido.
3. **Aislamiento de tenants es sagrado.** Un query nunca puede mezclar datos entre funerarias. Tests automatizados deben validar esto.
4. **CSRF en todos los forms.** Token por sesión, validado en cada POST/PUT/DELETE.
5. **RBAC en cada controller action.** Roles: `superadmin` (Crono), `tenant_admin`, `gerente`, `vendedor`, `operativo`, `contador`.
6. **Audit log de todo lo mutativo.** Cada INSERT/UPDATE/DELETE en tablas críticas → fila en `audit_log` con usuario, IP, timestamp, antes/después.
7. **Estética FIJA según design-kit.** Sin Bootstrap, sin Material UI, sin componentes pre-fabricados. Construir desde cero con Tailwind + theme.css.
8. **Georgia para display, NO Inter.** Body en system-sans. Mono solo para códigos/IDs.
9. **Mobile/tablet first** en pantallas que el vendedor usa en terreno (crear contrato, capturar firma).
10. **Soporte offline** crítico para vendedor en terreno: queue local + sync.

## Estructura del proyecto

```
/innovium-v2
  /public               ← document root del Apache
    index.php           ← front controller único
    /assets             ← compiled CSS/JS, fuentes, imágenes públicas
    /uploads-temp       ← uploads antes de subir a R2 (auto-clean)
  /app
    /Core
      Router.php
      Container.php
      TenantResolver.php
      Auth.php
      Csrf.php
      Storage.php       ← gateway R2 con prefijo automático
      Database.php      ← factory de PDO connections
      Request.php
      Response.php
      Validator.php
      AuditLog.php
    /Controllers        ← uno por módulo (ContratoController, etc.)
    /Models             ← uno por entidad
    /Services           ← lógica de negocio (no en controllers)
    /Middleware         ← AuthMiddleware, CsrfMiddleware, RbacMiddleware
    /Views              ← templates PHP
    /Helpers
  /resources
    /css                ← input.css (importa theme.css del design-kit)
    /js                 ← Alpine components, utilities
    /views              ← partials, layouts
  /design-kit           ← inmutable, source of truth visual
  /database
    /migrations         ← SQL versionado, formato 0001_descripcion.sql
    /seeds              ← datos de prueba
    /master             ← migrations específicas de innovium_master
    /tenant             ← migrations que se aplican a cada tenant
  /scripts              ← CLI tools
    innovium            ← comando principal: tenant:create, migrate, etc.
  /storage              ← logs, sesiones, cache
  /tests
    /unit
    /integration
    /tenant-isolation   ← tests críticos de aislamiento
  /docs
    ARQUITECTURA.md     ← decisiones técnicas
    SEGURIDAD.md
    DEPLOY.md
  .env.example
  .env                  ← gitignored
  .gitignore
  CLAUDE.md             ← este archivo
  README.md
  composer.json
  package.json
  tailwind.config.js
  postcss.config.js
```

## Convenciones de código

- **PHP namespaces:** `App\Core\`, `App\Controllers\`, `App\Models\`, etc. PSR-4.
- **Clases:** PascalCase. Métodos: camelCase. Constantes: SCREAMING_SNAKE.
- **Tablas SQL:** snake_case plural (`contratos`, `cuotas`, `tenant_users`).
- **Columnas SQL:** snake_case (`fecha_creacion`, `tenant_id`).
- **PKs:** siempre `id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY`.
- **Timestamps:** todas las tablas tienen `creado_en` y `actualizado_en` DATETIME.
- **Soft delete:** columna `eliminado_en` DATETIME NULL en tablas que lo justifican.
- **Charset:** `utf8mb4_unicode_ci` siempre.
- **Engine:** InnoDB siempre.
- **Migrations:** `database/migrations/{master|tenant}/NNNN_descripcion.sql`. Numeradas, idempotentes donde se pueda.
- **Rama principal:** `main`. Feature branches: `feat/<modulo>-<descripcion-corta>`.
- **Commits en español:** `feat(modulo): descripción`. Tipos: `feat`, `fix`, `refactor`, `style`, `test`, `chore`, `docs`, `design`.

## Cómo trabajar conmigo (Claude Code)

1. **Antes de empezar cualquier tarea:** leer `CLAUDE.md` (este), `docs/ARQUITECTURA.md`, y los archivos relevantes del módulo.
2. **Antes de implementar una pantalla:** abrir el SVG correspondiente en `design-kit/mockups/` y replicarlo exacto.
3. **Iteración visual:** después de cada pantalla, mostrar screenshot para validar contra mockup.
4. **Documentar a medida:** decisiones nuevas → `docs/ARQUITECTURA.md`. Convenciones nuevas → este archivo.
5. **Tests críticos sí, exhaustivos no.** Priorizar: TenantResolver, Storage, validación RUT, generación de cuotas, aislamiento.
6. **Preguntar cuando duda.** Especialmente sobre: arquitectura, seguridad, estética. Mejor preguntar que asumir.

## Contexto del legacy (para migración futura)

- Sistema PHP 7.4 actual: `systemserp_sgc`
- 143 tablas en BD, lógica crítica en `_venta_ni2.php` y `FLA7.js`
- Vulnerabilidades conocidas: SQL injection en `ajax_sql.php`, validación cliente bypasseada, password hashing débil
- Esquema sanitizado disponible: `analisis/esquema_legacy_sin_datos.sql`
- Auditoría documentada: `analisis/AUDITORIA.md` (a generar con Claude Code en local)

## Los 8 módulos del producto

1. **Gestión comercial** — contratos NI/NF, planes, convenios, origen del cliente, comisiones
2. **Cobranzas** — cuotas, recordatorios WhatsApp/email, pagos parciales
3. **Operaciones** — velatorios, capillas, vehículos, turnos
4. **Acompañamiento al duelo** ⭐ diferenciador — pacientes, psicólogos, sesiones, recordatorios
5. **Bodega** — inventario de cofres, urnas, flores, accesorios
6. **Personal/RRHH** — empleados, turnos, asistencia
7. **Comisiones a dateros** — registro de referencias, cálculo automático
8. **Obituarios públicos** — página pública por funeraria

## Roles y permisos (RBAC)

| Rol | Alcance | Permisos clave |
|---|---|---|
| `superadmin` | Cross-tenant (Crono Systems) | Crear tenants, configurar billing, ver métricas globales |
| `tenant_admin` | Un tenant | Configurar la funeraria, gestionar usuarios, ver todo |
| `gerente` | Un tenant | Dashboards, reportes, ver todos los contratos, no edita config |
| `vendedor` | Un tenant | Crear contratos, capturar firma, ver propios + asignados |
| `operativo` | Un tenant | Ver agenda de servicios, gestionar capillas/vehículos |
| `contador` | Un tenant | Cobranzas, registrar pagos, ver reportes financieros |

## Variables de entorno

Ver `.env.example` para la lista completa. Variables críticas:
- `APP_ENV` — `local` / `staging` / `production`
- `APP_DOMAIN` — `innovium.cl` (confirmado)
- `MASTER_DB_*` — credenciales BD maestra
- `R2_*` — credenciales Cloudflare R2
- `MAIL_*` — SMTP
- `WHATSAPP_*` — API de WhatsApp Business (proveedor por definir)

## Tareas inmediatas (Fase 0)

Ver `PROMPT_FASE0.md` en la raíz del repo.

## Build de CSS — CRÍTICO recordar

Tailwind NO recompila automáticamente. Cada vez que se agregan **clases nuevas** en una vista (cualquier `.php` bajo `app/Views/`, `resources/views/` o atributos en `public/index.php`), hay que correr:

```bash
npm run build       # one-shot, genera public/assets/css/app.css minificado
npm run dev         # watch mode, recompila al guardar
```

**Síntoma cuando se olvida:** las clases nuevas no aplican estilo (ej: `class="fixed inset-0 z-50"` no posiciona nada). El elemento aparece en el flujo normal del HTML como si las clases no existieran. Pasó en Sprint 1.4 con los modales — el form aparecía inline debajo de la tabla en lugar de flotante.

**Source paths que Tailwind escanea** (`tailwind.config.js`):
- `./app/Views/**/*.php`
- `./resources/views/**/*.php`
- `./resources/js/**/*.js`
- `./public/index.php`

`public/assets/css/app.css` está **gitignored** (es output, no source). Después de `git pull` en cualquier entorno (local, staging, prod) hay que correr `npm run build` para regenerarlo. La welcome page funciona sin él porque arranca con un CSS mínimo en `<style>` inline; el resto de las pantallas necesitan el build.

**TODO Sprint 2.x:** automatizar el build dentro del comando `php scripts/innovium serve` para no depender del usuario recordando correr `npm run build`. Opciones:
- Spawn `npm run dev` en background al arrancar `serve`.
- Wrapper Node que orqueste ambos.
- Pre-commit hook que rebuilde si detecta cambios en `.php`/`.js`.

## TODOs documentados

### Sprint 2.x · Tests E2E

Los tests actuales cubren backend (PHPUnit unit + integration: 159 tests / 475
assertions). NO cubren la capa JS de Alpine. Eso permitió que un bug
clásico (`form: this._empty()` en object literal) pasara desapercibido en
Sprint 1.4 hasta que Ricci abrió el navegador y vio errores en consola.

Acción: en Sprint 2.x agregar tests E2E con **Playwright** o **Cypress**
para validar los flujos de Alpine en las pantallas admin:
  - Modal abre al click "+ Nueva X"
  - Form valida client-side
  - Submit AJAX devuelve OK + toast verde
  - Tabla se actualiza sin recargar
  - Confirm dialog destructivo
  - Drag & drop de imágenes en /admin/productos
  - Builder de líneas en /admin/planes

Lo mínimo: 1 test E2E por pantalla (5 totales) cubriendo el flow CRUD
completo. Eso compensaría el gap actual.
