# Changelog

Registro de cambios relevantes de **Credify GO!**. Formato basado en
[Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/).

> 🧵 **Este archivo es el "hilo" del proyecto.** Cualquier agente de IA o dev que
> retome el trabajo desde otra máquina debe leerlo junto con [`CLAUDE.md`](CLAUDE.md)
> para entender el estado actual y lo que se ha hecho. Los `#NN` enlazan a PRs/Issues en GitHub.

## [Sin publicar]

### Pendiente — TODO (ver Issues)
- [ ] [#58](https://github.com/jdredondo/credify/issues/58) — Backups: el **dump diario local con retención ya existe en prod** (cron 03:15, `/home/credifygo/bin/db-backup.sh`, retención 14 días, verificación `gzip -t`, instalado 2026-06-16). **Falta la copia offsite** — hoy los dumps viven en la misma VM. Requiere credenciales de almacenamiento externo (Azure Blob / S3 / etc.).
- [ ] **Test flaky** — algunos tests fallan según el orden de ejecución por estado compartido en la BD de dev (visto en `PaymentMaterializerTest` — `QueryException` en la suite completa, pero **pasa aislado** 11/11 —, `DelinquencyMetricsService`, `CreditParentChildTest`, `ExtendWithInterestOperationTest`, y `SnapshotDashboardMetricsTest` —este usa `assertDatabaseCount('dashboard_snapshots', 1)` sobre la tabla completa, así que filas de snapshots de otras empresas persistidas en la BD dev lo rompen; **pasa en CI (BD limpia) y aislado**). Aislar el estado compartido o usar una BD de test dedicada.
- [ ] **Migrar `text-slate-*` crudo → tokens en la PWA** (deriva del [#43](https://github.com/jdredondo/credify/issues/43), Fase 3). El rediseño de dashboards ya usa tokens/`.pwa-card`; queda barrer el resto de vistas que aún usan `text-slate-*` inline para eliminar la deriva de color. Limpieza de bajo riesgo, no bloqueante.
> ✅ **Resuelto recientemente:** [#43](https://github.com/jdredondo/credify/issues/43) (UI/UX modo oscuro iOS-26 + dashboards modernos) **cerrado** — Fase B completa y desplegada: rediseño de los 3 dashboards (Approach C) + fix de modo oscuro, Fase 2 (utilidad mensual, desglose por cobrador, sparkline), snapshots para trends de stock, y el badge de rol de `ProfileView`; solo queda la migración `text-slate-*`→tokens (arriba). [#59](https://github.com/jdredondo/credify/issues/59) (ruido de notificaciones Filament — silenciado, ver `2026-06-19`) y [#60](https://github.com/jdredondo/credify/issues/60) (app-password de Bitbucket **revocado** en bitbucket.org; la VM ya estaba limpia). **#99** (`concurrently` 9→10) **cerrado**: v10 exige Node ≥22 (dev/prod en Node 20) y es dev-only; añadido a la lista `ignore` (majors) de `.github/dependabot.yml` — se retomará al migrar a Node 22.

### Cambiado
- **`installments.balance_due` pasa a llamarse `principal_balance_after`, y el campo homónimo de la API a `remaining_amount`.** El nombre viejo describía justo lo contrario de lo que guarda: no es lo que falta por cobrar de la cuota, sino el saldo de **capital que le queda al crédito después** de esa cuota — una columna de tabla de amortización. En un crédito de $500.000 a 10 cuotas, la primera (ya pagada, $70.000) la tiene en $450.000. El nombre invitaba al error y el error llegó a producción: tres widgets la mostraban como "Monto a Cobrar" / "Monto" / "Monto Pendiente", inflando el total del día 2,8x y ordenando mal la lista de prioridades del cobrador (corregido en #253). El código ya llevaba avisos sobre ella en dos servicios de métricas, pero un aviso dentro de un servicio no protege a quien escribe un widget. **Había además una segunda colisión, más sutil:** el endpoint de sincronización enviaba un campo llamado `balance_due` que llevaba **otra cosa** — `remaining_amount`, lo que sí falta por cobrar. El mismo nombre significaba dos cosas distintas según de qué lado del cable se mirara; ahora cada uno se llama como lo que es. La PWA lee el nombre viejo como respaldo para las filas que quedaron en IndexedDB antes del despliegue y aún no se han vuelto a sincronizar. La red de seguridad que impide exponer la columna como monto en un widget vigila ahora **los dos** nombres, porque el viejo puede reaparecer copiando código de una rama anterior. Migración reversible, verificada en los dos sentidos.

### Añadido
- **Archivar créditos: sacarlos de la ruta del cobrador sin cerrarlos (pedido por el dueño).** Hay créditos cuyo desenlace todavía no está decidido y que mientras tanto ensucian la cartera del cobrador. Archivar los esconde usando el estado `archived` que el modelo ya tenía pero que ninguna acción aplicaba. **No toca ningún registro financiero**: no borra el desembolso, no condona, no reescribe cuotas. Lo que sí implica —y por eso la confirmación lo dice con el número delante, *"salen $X de la cartera"*— es que `archived` pertenece a `CLOSED_STATES`, así que el saldo sale de "por cobrar", el patrimonio del panel baja en esa cantidad y no se le pueden registrar pagos. **Desarchivar recalcula el estado desde las cuotas** en vez de restaurar el anterior: mientras estuvo archivado el tiempo siguió corriendo, así que un crédito que se archivó al día puede volver en mora. Los archivados tienen **pestaña propia** y no se mezclan entre los cerrados, porque son una decisión pendiente y hay que poder encontrarlos. La acción queda reservada a admin y super_admin: mueve plata fuera de la cartera, no es decisión de campo. **`credify:verify-equity` sigue contando el capital archivado**: archivar retira el crédito de la cartera *operativa*, pero la deuda sigue siendo un activo — sin esto, archivar abriría una brecha en la conciliación del tamaño exacto de su saldo y el control nocturno empezaría a fallar por una operación legítima. Dos defectos encontrados por los tests durante el desarrollo: el grupo "Administrar" se ocultaba en los créditos cerrados con pagos, dejando **desarchivar fuera de alcance** (se podía archivar sin vuelta atrás), y faltaban los casos nuevos en los cuatro `match` exhaustivos de `CreditAuditEvent`. **Sin migraciones.**

### Corregido
- **El envío directo de cobros y visitas deja de ser más débil que el lote que lo respalda.** Desde PWA-002 el teléfono manda cada cobro primero a `POST /api/pwa/payments` y el lote reenvía con la misma clave si no hubo respuesta, pero el directo no aplicaba dos reglas del lote. **Clave retenida:** un POST directo atrasado (el teléfono ya se había rendido y el lote había retenido la fila) se aplicaba igual, saltándose la revisión; ahora el directo busca la clave también en `held_payments` y devuelve el retenido (`held: true`, sin `payment`) en vez de aplicarlo, y la pantalla avisa que quedó en revisión. **Cobro igual el mismo día (decidido al revisar PWA-002: preguntar):** el lote retenía como "posible duplicado" un cobro con el mismo monto, el mismo día y el mismo crédito que otro ya registrado; el directo lo aplicaba en silencio, y desde PWA-002 era justo el caso del cobrador que repite un cobro tras "No se pudo confirmar con el servidor" (sale con otra clave). Ahora el directo responde 409 con el pago que ya está y la pantalla de pago pregunta "Ya hay un pago de $X hoy a las HH:MM. ¿Es un cobro distinto?" **sin soltar la fila**: "Sí" la reenvía con la misma clave y `confirm_duplicate`, que queda en la fila y el lote respeta (solo para los pagos iguales capturados antes que ese cobro); "No" la borra; sin respuesta (salir de la pantalla, la app cerrada) la fila sube sola al vencer la pregunta y el lote la retiene para el admin. Nada se pierde. La regla vive en un solo lugar (`PaymentSyncService::sameDayDuplicate()`). Un teléfono con el bundle anterior recibe el 409 como error, con un mensaje que dice cómo seguir. **Visita en carrera:** dos envíos con la misma clave chocaban en el índice único y el segundo respondía 500; ahora devuelve la visita existente como duplicado, como el lote. Spec `docs/superpowers/specs/2026-10-01-directo-como-el-lote-design.md`. **Sin migraciones.**
- **Una versión nueva de la PWA ya no recarga la app en pleno envío, ni saca al cobrador de la sesión sin preguntar.** Tras un deploy, la app se recargaba sola en cuanto volvía a primer plano, hiciera lo que hiciera. Una recarga durante la espera del GPS (hasta 6 s antes de armar el cobro) perdía el toque: la fila todavía no estaba en IndexedDB. Y como el token vive solo en memoria, toda recarga obligaba a volver a entrar, algo que con señal débil puede no salir. Quitar el `updateSW(true)` de `main.js` no alcanzaba: el SW hacía `skipWaiting()` en `install` y el plugin recargaba solo en el `clients.claim()`. Retrasar solo la recarga tampoco: el SW nuevo borra los chunks de la versión vieja y la siguiente navegación terminaba en otra recarga por `router.onError`. Ahora el SW nuevo **espera** en `waiting` mientras el viejo sigue sirviendo su precache completo, y `services/actualizacion.js` decide: se aplica sola solo en el login sin token (recién abierta, tras un 401 o el vencimiento de 24 h, al cerrar sesión) y si es la única ventana de la app (`skipWaiting` cambia la versión para todas, y otra pestaña puede tener una sesión: el SW lo comprueba). Con sesión viva aparece "Hay una versión nueva · Actualizar" (en `PwaHeader` y en Inicio), que exige señal: perderla anula lo pedido, porque después habría que volver a entrar. En los dos casos, nunca con algo en curso: un envío (`enviarOEncolar` y el submit de cobro, "No paga", gasto, crédito, socio, cliente y login, con la espera del GPS incluida), anular un pago, aprobar o rechazar un gasto, reordenar la ruta, guardar ajustes, una ronda de sincronización, el login a medio escribir, o un cierre de sesión mientras vacía la caché (borra el token antes). Cada envío sigue contando 8 s después de terminar, para que el recibo o el aviso de "puede que sí haya quedado" se alcancen a leer: quien no los ve lo vuelve a registrar. La recarga la decide `controllerchange`, no el plugin, que no recargaba una página que arrancó sin SW. Una versión que ya esperaba al cargar la página (la carga completa tras un 401) se avisa antes de que el plugin entregue la registración; se aplica cuando llega, en vez de descartarse. E2E nuevo `actualizacion-sw.spec.js` (un login, con el SW habilitado solo ahí): simula deploys reescribiendo `public/pwa-sw.js`, porque Playwright no intercepta la descarga del script del SW. Falla con el código anterior, y también si se quita el envoltorio de la pantalla de pago, los 8 s de reposo, la retención del login, la espera de la registración, la condición de ventana única, la de ruta de login o la anulación al perder la señal. De paso, `registration.update()` sin señal ya no manda un rechazo no atrapado a Sentry en cada vuelta a primer plano. **Al desplegar:** solo frontend, y fuera del horario de cobro una última vez: los teléfonos con el bundle anterior todavía recargan sin guardia al recibir esta versión.
- **El gasto y la visita "No paga" guardan lo mismo vayan por el envío directo o por el lote.** Desde PWA-002 la misma fila de la cola sale primero por `POST /api/pwa/expenses` o `/api/pwa/visits` y, si la respuesta se pierde, la vuelve a mandar el lote con la misma clave; pero los dos caminos no seguían las mismas reglas. **Modo simplificado:** el directo convertía solo si la empresa tenía el modo prendido *hoy*; el lote, por el flag del ítem (PWA-010). Un teléfono con los ajustes viejos en caché que mandaba "50" con el flag después de que la empresa apagó el modo guardaba $50 por un camino y $50.000 por el otro. Ahora el gasto y la promesa de pago del directo convierten por el flag con `SimplifiedAmount::toReal`, que además redondea igual que el lote, y leen el flag igual que él (`"true"` u `"on"` en texto daban 422 solo en el directo). **Fechas:** el directo rechazaba un gasto o una visita con fecha de mañana y una promesa para ayer, que el lote acepta por la zona horaria del teléfono; con un 422 el teléfono suelta la fila como rechazada de verdad. Ahora tienen un día de margen (más de un día sigue frenado en el directo, que solo recibe filas recién capturadas). La promesa de $0 también pasa, como en el lote. El gasto directo guarda `metadata.source = pwa_direct` (el lote guarda `pwa_sync`), para saber por cuál entró. Test nuevo `DirectoYLoteMismasReglasTest`, que manda el mismo payload por los dos caminos y compara las dos filas guardadas enteras (salvo id, clave, fechas de registro y `metadata`); el test que afirmaba lo contrario (`test_expense_does_not_convert_when_master_off`) ahora afirma la regla del lote. **Sin migraciones.**
- **La visita "No paga" y el gasto avisan si ya hay uno esperando señal, y "Mis gastos" muestra los que siguen en el teléfono (seguimiento de PWA-002).** Con PWA-002 una visita o un gasto cuya respuesta se pierde queda en la cola y sube después, pero solo la pantalla de pago avisaba lo que ya estaba en la cola. Un cobrador que salió a mitad del envío y volvía podía registrar la misma visita o el mismo gasto otra vez, con otra clave, que el servidor no puede reconocer como el mismo; y el gasto de un admin se aprueba solo. Ahora el modal "No paga" avisa por crédito y el formulario de gasto avisa de todos los gastos propios en la cola, con las mismas reglas que el aviso de cobros: cuentan también los que se están enviando, los que agotaron reintentos se avisan aparte y nada bloquea. Los tres avisos dicen la fecha cuando lo guardado no es de hoy, y el monto real aunque se haya escrito en modo simplificado. "Mis gastos" lista arriba los gastos propios de la cola con la marca "En el teléfono, sin subir", fuera del total. No muestra como "sin subir" el que el servidor ya tiene: `/api/pwa/expenses/mine` devuelve la `idempotency_key` y la clave los empareja. Cuando el motor sube uno, la lista se recarga, y hasta que la lista lo trae sigue a la vista con "Ya subió", aunque la recarga falle. Las categorías de lo que está en la cola usan las etiquetas del servidor, guardadas en el teléfono, para que el gasto no cambie de nombre al subir. Si la lista del servidor no carga, dice "No se pudieron cargar los gastos que ya subieron." en vez de "Sin gastos en este período". Limitación conocida: el emparejamiento solo ve el período elegido, así que un gasto con la respuesta perdida y fecha anterior se ve como "sin subir" hasta la siguiente ronda. Sin migraciones. Pasos nuevos en `senal-debil.spec.js`, dentro de su único login; uno fija las unidades con una fila en modo simplificado, porque el demo arranca sin él.
- **Con señal débil, un cobro, una visita "No paga" o un gasto ya no se pierden, y un crédito o un movimiento de socio ya no se duplican al reintentar (PWA-002).** En la calle lo normal es una señal que el teléfono da por buena y que no llega al servidor. La app solo usaba la cola cuando el teléfono se declaraba sin conexión: el cobro esperaba hasta 30 s, mostraba un error y no quedaba en ninguna parte; si el cobrador insistía desde otra pantalla salía con otra clave y, si el primero sí había llegado, se cobraba dos veces. Ahora cobro, visita y gasto se guardan en la cola **antes** de salir, con la misma clave con que se envían, y solo se sueltan cuando el servidor responde: si no responde en 10 s quedan guardados y los sube el motor de PWA-001; si el primer envío sí había llegado, el servidor lo reconoce por la clave. Una marca en la fila evita que el motor mande lo que la pantalla está enviando y vence sola si la app muere a mitad; tras un envío directo exitoso se sube el resto de la cola. Si IndexedDB no puede guardar, con señal se envía directo como antes. La pantalla de pago abre desde la caché del teléfono si el servidor no contesta en 8 s (antes esperaba 30 s y decía "Crédito no encontrado"), avisa si ya hay un cobro de ese crédito esperando señal y no deja volver a tocar "Confirmar" mientras sale. El GPS se pide al abrir y se espera como mucho 6 s. "No quedó registrado" solo se dice cuando es seguro (sin señal y sin dónde guardarlo); en la duda, la app avisa que pudo haber quedado. El gasto con conexión, que ignoraba la clave, ahora deduplica y se crea y aprueba en una sola transacción. Crédito y movimiento de socio, que no van a la cola, llevan una clave nueva (`idempotency_key` con índice único por empresa en `credits`, `capital_contributions` y `partner_withdrawals`): el teléfono la guarda por usuario hasta que el servidor confirma —hasta el fin del día, aunque se cierre la app— y el reintento devuelve el original, con un aviso de sus valores, en vez de crear otro crédito con otro desembolso. El chequeo de duplicado va antes del límite del plan y del saldo del socio, y se repite antes de rechazar, para que ni el reintento ni una carrera se rechacen por su propio efecto. Migración reversible; la base del teléfono no cambia de versión. E2E nuevo `senal-debil.spec.js` (un solo login), y la suite E2E corre con la zona horaria de Bogotá.
- **La cola offline por fin sube, y ningún cobro capturado en campo se pierde (PWA-001, auditoría 2026-09-24).** Ningún pago, visita ni gasto guardado sin señal había llegado nunca al servidor: en producción había 0 ítems venidos de la cola en toda la historia. La sincronización consultaba `where('permanent_error').equals(true)` e IndexedDB no admite booleanos como clave, así que la promesa rechazaba siempre, aun con la cola vacía; en pagos además dejaba `syncing` trabado hasta recargar. Reproducido con Dexie 4.4.4 en Chromium y WebKit. **Teléfono:** Dexie v8 quita esos índices y sella en lo ya encolado quién lo capturó; un solo motor reemplaza las tres copias (lotes de 50/100, un fallo de red no penaliza, una fila mala no traba la ronda, un 403 del lote entero no castiga la cola), y la cola sube al entrar, al volver a primer plano, al volver la señal y cada 3 minutos. Cambiar de usuario ya no borra la cola del anterior: se sube a su nombre (PWA-011). La pantalla de errores lee de IndexedDB, muestra solo lo del usuario de la sesión, está enlazada desde el indicador, Inicio y Pagos de hoy, cambia "Descartar" por "Enviar a revisión" en pagos y ya no se queda en blanco por el locale `es_CO` (PWA-008). Los fallos de la cola llegan a Sentry, con transporte offline. **Servidor:** cada lote decide ítem por ítem (una fila mala ya no devuelve 422 al lote entero) y cada resultado trae su `index`. **Principio nuevo:** un cobro se aplica o queda **retenido** en `held_payments`, sin tocar saldos, si llegó con más de 7 días, tiene fecha futura, apunta a un crédito que cambió o sin cuotas pendientes, supera el saldo, parece un duplicado (mismo monto el mismo día; o, si pasó más de 24 h en la cola, un pago igual a ±7 días cargado después de capturarlo), lo capturó otro usuario del teléfono o el cobrador lo mandó a revisión. El admin lo aprueba o rechaza en **Cobros en revisión** (Filament), con pista de posibles duplicados (también en la confirmación de aprobar); aprobar bloquea el crédito, registra el pago con la fecha, la clave y el cobrador originales, y nunca crea dos pagos. Gastos y promesas escritos en modo simplificado ya no llegan mil veces menores (PWA-010): la empresa 1 tiene ×1000. Tampoco los cobros que `PaymentView` encoló crudos del 2026-02-05 al 2026-03-09: el motor manda el flag y el servidor los convierte (`App\Support\SimplifiedAmount`). La migración de `held_payments` se niega a revertirse si la tabla tiene filas. Los errores del lote se reportan sin los valores del cobro (`App\Support\SafeReport`). De paso, `MultiTenantScope` usa `getAttribute/setAttribute` y se quitan **21 supresiones** del baseline de PHPStan. **Al desplegar:** los teléfonos subirán lo atascado; lo de más de 7 días aparecerá en la bandeja para revisarlo con el admin.
- **El listado de créditos vuelve a la posición donde estaba, y cada crédito muestra la dirección del cliente (reportado por el cobrador).** Al abrir un crédito y volver atrás, el listado aparecía desde el principio: el cobrador tenía que volver a bajar hasta donde estaba. El router ya devolvía `savedPosition`, pero no alcanza — la vista se re-monta y vuelve a pedir los créditos, así que cuando se restaura el scroll la lista todavía está vacía y no hay a dónde bajar. Ahora la posición se recuerda **solo al entrar a un crédito** y se restaura cuando la lista ya está pintada; salir por la barra inferior la olvida, porque volver a "Créditos" desde el menú debe mostrar el principio. Cambiar de filtro también la descarta. **Dirección:** viajaba en cobros y en el snapshot offline, pero el listado de créditos era el único que no la mandaba. Se añade a `formatCreditForList` (con `client.addresses` en el eager load, si no serían N consultas) y a la caché offline, y se pinta en `CreditCard` con el mismo tratamiento que la card de cobros — así aparece en todos los listados que usan esa card. De paso, `Credit::client()` declara su genérico: sin él PHPStan veía `BelongsTo<Model>` y no reconocía los accessors de `Client`, lo que permitió **eliminar 87 líneas de supresión del baseline** en diez archivos que llevaban tiempo sin verificarse de verdad.
- **Cerrar sesión en la PWA borraba los pagos registrados sin señal.** `logout()` hacía `db.clearAll()`, que arrasa `pendingPayments`/`pendingVisits`/`pendingExpenses` junto con la caché. Un cobrador que cerraba sesión con pagos en cola los perdía: esa fila no existe en ningún otro lado — es plata ya cobrada al cliente. Ahora hay dos caminos: `endSession()` termina la sesión sin tocar IndexedDB (sesión caída) y `logout()` limpia **solo la caché de lectura**, conservando la cola. Esta se descarta únicamente si en el dispositivo entra un usuario distinto, porque el servidor atribuye cada pago según el token de quien sincroniza; el aviso de la vista ahora lo explica en vez de amenazar con perderlos. (Ya no: desde el arreglo de la cola offline, PWA-001, la cola del usuario anterior no se descarta nunca; se sube a su nombre y sus cobros quedan en revisión.) **Corrección a lo reportado en la auditoría:** la rama de 401 de `fetchUser()` NO estaba perdiendo datos en producción — solo corre al montar la app y sale temprano sin token en memoria, y el interceptor redirige antes de que el `clearAll()` alcance a ejecutarse. Era una mina latente; el daño real estaba en el cierre voluntario.
- **Dos dispositivos distintos compartían nombre de token y se expulsaban entre sí.** El nombre era `PWA-<userAgent recortado a 50>`: en producción el cobrador y el dueño tenían exactamente la misma cadena (dos iPhone con el mismo iOS), así que la revocación «un token por dispositivo» de #254 cerraba la sesión del otro — y ese cierre involuntario se combinaba con el bug anterior. Pasa a usar el `device_id` que la PWA ya generaba, persistía y enviaba, y que `registerDevice()` ya usaba: el login se había quedado atrás. Los tokens del esquema viejo se descartan en el primer inicio de sesión de cada usuario. `generateDeviceId()` pasa a `crypto.randomUUID()` con respaldo.
- **Quedaban dos rutas de sincronización con reglas distintas.** `syncInitialData()` (auth.js) seguía usando `bulkPut` crudo porque la purga de #251 solo cambió `syncData()`: la sincronización del login no purgaba, así que un crédito fuera de alcance seguía visible hasta el siguiente sync de fondo. Ambas pasan ahora por `applySyncSnapshot`. (Ya no: desde el arreglo de la cola offline, PWA-001, `syncInitialData()` no existe; el login sube la cola con `syncAll()` y después descarga con `syncData()`.)
- **`credify:verify-equity` no podía detectar un aporte de capital duplicado.** El error infla los **dos** lados del puente por igual —sube el capital aportado y sube la caja— así que la brecha seguía dando cero: con los $1.500.000 registrados dos veces en producción la conciliación cuadraba perfecta. Se añaden dos señales que el puente no puede ver: mismo monto en la misma fecha, y capital que entra sin socio vinculado en `capital_contributions`. Contra la copia de producción ambas marcan el ingreso #483.

### Eliminado
- **`app/Repositories/` completo.** `PaymentRepository` no tenía un solo llamador en producción (el único uso era un test reciente); `InstallmentRepository`, encontrado al verificarlo, está igual. Código muerto que además había que mantener al día con cada cambio de semántica de pagos.

### Añadido
- **Playwright entra al CI** como job aparte, solo chromium. El login de la PWA está limitado a 5/min por IP+identificador y en CI todo sale de una sola IP: añadir el proyecto `mobile` duplicaría los inicios de sesión y haría fallar la suite por 429, no por un bug. Cubre lo que ningún test de PHP puede ver — el comportamiento de IndexedDB en un navegador real, en particular la purga del snapshot de sync, que **borra filas del dispositivo del cobrador**. Dos specs nuevos: uno fija el invariante de que una sesión caída no toca la cola (pasa también con el código viejo: es invariante, no regresión) y otro que sí tiene dientes, y falla con el `clearAll()` del cierre voluntario.

- **Anular un pago restaba el monto dos veces de los cobros reportados (hallazgos 4, 6 y 7 de la auditoría).** `PaymentReverser` deja DOS filas: la original con `voided = 1` y un asiento compensatorio de monto **negativo** que **no** queda marcado como anulado. Filtrar solo por `voided = false` excluía la original *y encima* sumaba el negativo. En producción subestimaba los cobros: junio −$284.667, julio −$132.000, agosto −$190.000 ($606.667 acumulado, 0,2% sobre $278M, pero hasta 1% de un mes puntual). La caja nunca se vio afectada —al anular se borra el ingreso—, el daño vivía solo en las sumas sobre `payments`. Tres sitios ya lo excluían a mano (`CreditController`, `SyncController`, `CreditRules`, este último tras el bug #387 que dejaba el crédito en solo lectura después de anular su único pago); ahora hay **una sola definición**, `Payment::scopeCollections()`, exigiendo los dos marcadores (`payment_method != 'reversal'` y `original_payment_id IS NULL`, que en producción coinciden exactamente), aplicada en los **25 sitios** que miden dinero cobrado —incluidos `PaymentRepository` y `CashFlowComparisonChart`, que el barrido inicial no había encontrado— y en `Credit::latestPayment()`, donde al anular el último pago el "último pago del cliente" pasaba a ser un monto negativo. La vista de auditoría de Filament sigue mostrando ambas filas a propósito. **Higiene de accesos:** la PWA creaba un token por inicio de sesión y nunca borraba el anterior (un solo cobrador acumuló **495 credenciales válidas simultáneas**; la tabla llegó a 1.411 filas, 826 ya expiradas, para una empresa de 3 usuarios); ahora el login revoca el token previo del mismo dispositivo y se programa `sanctum:prune-expired` semanal. **Conciliación de patrimonio:** nuevo comando **`credify:verify-equity`** —hermano de `credify:verify-interest`— que concilia el patrimonio por dos caminos independientes y falla si la brecha supera la tolerancia; modela con nombre las tres causas que en la auditoría la explicaban ($380.000 de interés capitalizado al refinanciar, $200.000 de **capital** condonado bajo una categoría que el enum trata como si nunca tocara el capital, y $60.000 de sobrepago que es saldo a favor del cliente y no patrimonio). De paso se anotan los genéricos de `Credit::payments/collector` y `Payment::installments`, lo que permitió **reducir el baseline de PHPStan de 261 a 251 entradas** (20 inserciones contra 80 eliminaciones). **Sin migraciones.**
- **Los widgets de cobro mostraban el saldo de amortización en vez del monto a cobrar (detectado en auditoría, afectaba la operación diaria).** `installments.balance_due` es el saldo de **capital que le queda al crédito después** de esa cuota — una columna de la tabla de amortización que no tiene relación con lo que el cliente paga ese día. Tres widgets la exponían cruda: `InstallmentsDueTodayTable` como **"Monto a Cobrar"** (y además **ordenaba la lista del día por ella**), `OverdueInstallmentsTable` como "Monto" y `CollectorDueInstallmentsTable` como "Monto Pendiente". En producción el total del día aparecía como **$2.772.942 cuando lo real era $1.002.200** (2,8x), a un cliente que debía $68.200 le mostraba $540.000, y la cifra era incorrecta en **1.054 de 1.055** cuotas pendientes. Peor que el monto: como el widget del supervisor ordenaba por esa columna, la lista de prioridades de cobro salía ordenada por el tamaño del crédito en vez de por lo que hay que recaudar. Los tres pasan a `Installment::remaining_amount` (`total_amount - amount_paid`, acotado a 0 — el accessor ya existía), con ordenamiento por SQL para no perderlo. El código **ya conocía la trampa** (`PortfolioMetricsService` la documenta: *"Sumar balance_due de todas las cuotas produce un número sin significado financiero"*), pero la advertencia vivía en un servicio de métricas y los widgets nunca se enteraron. Se añade una **red de seguridad**: un test recorre `app/Filament/Widgets/` y falla si algún widget vuelve a exponer `balance_due` como columna de dinero. **Sin migraciones.**

### Cambiado
- **Acceso demo por captura de lead + credenciales por correo (se elimina el login passwordless).** El acceso passwordless `GET /demo/{role}` era un hueco de seguridad (cualquiera con la URL entraba, sin capturar datos). Se reemplaza por una **página dedicada `/demo`** (`noindex`, rate-limit) que explica la demo y **captura el lead** (nombre/correo/WhatsApp/negocio → `Lead` con `source=demo_request`, visible en Filament → Leads) y le **envía por correo** las credenciales de los 3 usuarios demo (Dueño/Supervisor/Cobrador) + enlaces a la app (`/pwa/login`) y al panel (`/admin`). El prospecto **inicia sesión normal** — sin auto-login. Las 3 contraseñas son **fijas compartidas, cifradas en reposo** (tabla `demo_credentials`, cast `encrypted`) y **se rotan en cada `reset-demo` nightly** (credenciales filtradas expiran en ≤24 h); `setup-demo` las fija si faltan (no las rota en cada deploy). Correo (`DemoCredentialsMail`) **en cola** y **agnóstico del proveedor** (por `.env`; recomendado Resend). **Se elimina** `DemoAccessController`, la ruta `/demo/{role}`, la vista `demo-enter`, el bootstrap por `sessionStorage` (`main.js`/`stores/auth.js`) y `PwaAuthResponse::issueToken`. Los CTAs "Probar demo" de la landing pasan a enlazar `/demo`. **Dependencia de despliegue:** el envío real requiere configurar el proveedor de correo + verificar el dominio (SPF/DKIM); hasta entonces `MAIL_MAILER=log`. Ver `docs/superpowers/specs/2026-08-07-demo-lead-access-design.md`.
- **Trial reemplazado por una empresa demo compartida (menos carga, cero pileup de empresas).** En vez de que cada prospecto se auto-registre creando su propia empresa (`/registro`, retirado), hay **una única empresa demo** (flag `is_demo` en `companies`, invariante: exactamente 1) en plan Profesional con suscripción permanente y **3 usuarios demo fijos**. La landing pasó de "Empieza gratis" a **"Probar demo"** con selector de rol → login **passwordless acotado** (`GET /demo/{dueno|supervisor|cobrador}` → solo loguea usuarios de la empresa `is_demo`) → PWA; los CTAs de precios pasan a **"Solicitar cuenta"** (lead) y el onboarding real es manual. **Reset seguro** de los datos demo: comando `credify:reset-demo` (agendado **nightly 04:00 `--force`** + intra-día cada 3 h **si cualquier métrica de uso ≥ 90 %**) que borra **solo** lo tenant de la empresa demo — guards: exactamente-1-demo, filtro `company_id` explícito + `withoutGlobalScopes()` (el scope multi-tenant está OFF en consola), **allowlist** de tablas, y **preserva** empresa/suscripción/usuarios/settings — y re-siembra un dataset realista (`DemoDataSeeder`: ~20 clientes + 18 créditos en varios estados vía el flujo real de créditos). Provisión idempotente con `credify:setup-demo` — que ahora **también siembra el dataset de ejemplo si la demo está vacía** (con `--no-seed` para omitirlo), de modo que una demo recién provisionada nunca se vea en ceros; el seeder **enlaza al supervisor con el cobrador** (`supervisor_collector`) para que también la vista de supervisor muestre equipo y métricas (antes salía "sin cobradores"); y **siembra el capital inicial de 2 socios** (~$20M vía el flujo real de aportes, `PartnerInvestmentService`) para que la caja arranque **positiva** — sin el aporte, los 18 desembolsos dejaban la caja en negativo. El "wipe" de tenant se extrajo a `ResetDemoCompany::wipeTenantData()` (reusado por los tests demo para partir de una demo vacía aunque la BD compartida ya la tenga poblada). Cubierto con tests, incluido el guard **"el reset nunca toca una empresa real"**. **Copy veraz:** se eliminó **toda** mención de "prueba gratis 14 días" del sitio (landing `welcome.blade.php`, bot `sales-bot.blade.php` y el `Offer` de JSON-LD) — como ya no hay trial auto-servido, el único gancho gratis es la **demo** (sin registro ni tarjeta, con datos de ejemplo) y la cuenta real se **solicita** y se activa manualmente al coordinar el pago. Ver `docs/superpowers/specs/2026-08-01-demo-company-design.md`.
- **Los créditos ahora tienen un código secuencial por empresa (001, 002, …) en vez del `id` global.** Antes el "código" visible del crédito era el PK global (`Crédito #{id}`), así que el primer crédito de una empresa podía salir como #500. Ahora cada empresa numera su propio talonario desde 001: nueva columna `company_credit_number` con índice único `(company_id, company_credit_number)`, asignada al crear vía un **hook `creating` del modelo `Credit`** (cubre TODOS los paths de creación —nuevo/refinanciar/renovar/reestructurar/clonar— con `lockForUpdate` para serializar concurrentes; el índice único es la red de seguridad). **Backfill cronológico** de los créditos existentes (por empresa, en orden de creación; los anulados conservan su número, sin renumerar). El código formateado (`Credit::code`, `str_pad` a 3 dígitos) se expone en la API + IndexedDB (con degradación offline) y se muestra en la PWA (detalle, pago, cliente, recibo, historial). **En el panel Filament el crédito se identifica por su Código en todas partes**: tabla de créditos (columna "ID" cruda → oculta como "ID interno" toggleable; el "Código" queda como identificador visible), lista de créditos del Cliente, encabezado y notificaciones de la vista de crédito (crear/duplicar/extender/condonar/eliminar), el selector de crédito en Gastos y las referencias en la tabla de Pagos/anulación. El id interno solo se conserva donde es una referencia técnica (sección "Información técnica" colapsada, columnas ocultas) o una FK/argumento de servicio. Anotación `@property-read string $code` en el modelo para el tipado. El `id` interno, rutas y FKs **no cambian**. Tests: asignación correlativa por empresa, independencia entre empresas y backfill cronológico. Ver `docs/superpowers/specs/2026-08-01-credit-numbering-design.md`.
- **Entrada simplificada: un solo toggle maestro + ampliada a gastos, socios y promesa de pago.** El "modo simplificado" (capturar montos sin ceros, `1500`→`$1.500.000`) ahora depende **solo del toggle maestro** — se eliminaron los toggles por área "En créditos"/"En pagos" de Ajustes; el gating backend (`CompanyFinancialSettings::isSimplifiedInputEnabled()`) pasó a ser **master-only** (la columna `simplified_input_contexts` queda inerte, sin migración). Y se **aplica** también en **Registrar Gasto**, **Transacción de Socio** y el **Monto Prometido** de la visita "no paga hoy" (patrón de crédito: el form envía el monto crudo + `is_simplified_amount`, el backend convierte con `convertSimplifiedToReal()`; sin doble multiplicación; soporte offline). Componente `SimplifiedAmountInput` ampliado a los contextos `expense/partner/promise` y gateado por el maestro. Tests backend nuevos (`SimplifiedInputExpandTest`, 6/17). **Nota:** la página Filament de ajustes aún expone un control por contexto que ahora es inerte (follow-up de back-office). Ver `docs/superpowers/specs/2026-08-01-simplified-input-expand-design.md`.
- **Follow-ups de UX de la PWA tras el QA del fix de overlays (#225).** Tres ajustes menores: **FAB como superficie única de acciones** — se elimina el componente `QuickActionRow` (y sus arrays `primaryActions`/`moreActions`) de los 3 homes (admin/supervisor/cobrador), que duplicaba las acciones ya cubiertas por el FAB y las tabs; **entrada a Ajustes de empresa en el Perfil** (visible solo para `admin`, vía `authStore.isAdmin`) — la ruta `/pwa/company-settings` quedaba huérfana sin ningún punto de entrada en la UI; y se **documenta que el recibo de pago (`PaymentReceipt.vue`) es claro por diseño** — es un documento imprimible/compartible con el cliente (`@media print`), no debe migrarse a modo oscuro. Solo PWA, sin cambios de backend. Ver `docs/superpowers/specs/2026-08-01-pwa-ux-followups-design.md`.
- **Rediseño de la landing (`welcome.blade.php`) a tema completamente oscuro.** Paleta unificada al **emerald** de marca (fuera teal/cyan — 81 utilidades + 8 gradientes/aurora), **hero con titular dominante** (64px en desktop) y el cluster de mockups reducido a **2 cards** (Recaudo del día + Alerta de mora, blancas sobre el fondo oscuro). Y **tema oscuro en TODA la landing**: el cuerpo (secciones, tarjetas → glass oscuro, nav) pasó de claro a oscuro, **eliminando la transición murky hero→cuerpo** (que se veía mal) y unificando el look con la app. Solo diseño — sin cambios de copy, estructura ni backend. **Fix de rendimiento móvil:** las auroras `blur-[120px]` animadas ya no cargan en móvil (`hidden md:block`) — el `.hero-dark` ya trae los glows radiales baratos —, resolviendo el fondo negro de 2-3 s al scrollear. Motivado por un clon (`credifyapp.com`): se toma la *dirección* (sobriedad + verde oscuro) corrida a nuestro emerald, con nuestros propios assets/producto. Verificado en vivo (build + preview). Ver `docs/superpowers/specs/2026-07-30-landing-elevation-design.md`. *(SEO queda como track aparte.)*

### Añadido
- **Documentación de usuario del panel de salud del negocio** (`docs/reportes/salud-negocio.md`, publicada en el sitio público de docs). Explica **tarjeta por tarjeta** qué es cada cifra y cómo se calcula: patrimonio, caja (y por qué un desembolso la baja sin ser un gasto), utilidad neta, interés cobrado (con el ejemplo de la imputación interés-primero), margen, rendimiento mensual, el criterio PAR de la barra vigente/vencida, el aviso ámbar de "crece mientras la caja baja o la mora sube", la meta y la tasa de cobro del día, y el "van N de M días" del ritmo. Documenta explícitamente el **contrato anti-ceros** en lenguaje de usuario (número normal / "sin datos suficientes" / ámbar con aviso) y responde **"¿por qué mi margen sale casi del 100%?"** distinguiendo los dos casos posibles (gastos aún no cargados vs. gastos que se llevan fuera de Credify). Se añaden al glosario `Caja`, `Interés cobrado`, `Margen`, `Patrimonio`, `Rendimiento mensual`, `Saldo por cobrar` y `Utilidad neta`, y se enlaza la página desde el índice de Reportes con su fila en la tabla de permisos (solo admin). **Sin cifras reales del negocio**: el sitio de docs es público, así que todos los ejemplos usan montos ilustrativos.
- **Dashboard de salud del negocio para el dueño (PWA + Filament).** El panel mostraba **"Utilidad mes: $0"** porque esa métrica busca ingresos con categoría `loan_payment_interest`, que **ningún servicio escribe**: los 2.272 pagos de la empresa real se registran con la categoría genérica `payment`, sin separar capital de interés. Ahora la rentabilidad se **deriva del ledger de cuotas** con imputación *interés-primero* sobre `payment_installment` (`InterestAttributionService`), y `BusinessMetricsService` compone utilidad neta, margen, rendimiento y flujo de caja por periodo (rolling 30 días contra los 30 previos), con **exclusión explícita de `payment` y `loan_payment_interest`** para no contar dos veces. Anclado a datos reales con el nuevo comando **`credify:verify-interest`**: dos caminos independientes dan **$50.060.363,91** con brecha $0,00. **Trampa encontrada y evitada:** la columna `expenses.affects_profit` está mal poblada en producción (**$84M de desembolsos** y **$10,6M de retiros de utilidad** marcados en 1) — usarla habría dado una utilidad histórica de **−$45M** en vez de **$49,5M**; la regla decide por el enum `ExpenseCategory` y excluye además los retiros, que son reparto y no costo de generar la utilidad. El panel se reordena a **patrimonio y caja → rentabilidad → cartera y riesgo → ritmo**, toda métrica viaja con un **contrato anti-ceros** (`ok`/`no_data`/`unreliable`: sin costos cargados, utilidad *y* margen se marcan `unreliable` en vez de presumir un 98% sano), y se corrigen dos engaños del panel viejo: la **meta del día del dueño** (siempre en 0 porque el backend solo la mandaba al cobrador) y la **polaridad** de "por cobrar" (se pintaba verde aunque la caja cayera y la mora subiera; ahora es `watch`). Sale a la luz lo que ya se calculaba y nadie mostraba: **tasa de cobro del día, cuotas vencidas y cobradores activos**; el sparkline del dueño pasa de 7 a 30 días. Las dos superficies leen del **mismo servicio** (verificado: ambas dan $5.360.158 / +13,1% / 98,2%). De paso se completó `ExpenseCategory::ADMIN_EXPENSE` en los 5 `match` que lo omitían —uno se llama en **cada guardado de gasto**— y se eliminó su excepción del baseline de PHPStan. **Sin migraciones.** Ver `docs/superpowers/specs/2026-09-08-dashboard-salud-negocio-design.md`.
- **Analítica de producto con PostHog (PWA Vue, Fase 1).** `posthog-js` integrado en la PWA: helper `resources/js/pwa/analytics.js` (init/identify/capture/reset, todo no-op sin key) + init en `main.js` + `identify`/`login`/`reset` en el store de auth. Región **US** (`us.i.posthog.com`). Mide: **pageviews del SPA** (`capture_pageview: 'history_change'`) + **`identify`** por `user.id` con `role` + `company_id` (cohortes/funnels) + evento `login`; `reset()` en logout. **PII de fintech blindada:** **autocapture OFF** (no captura texto de elementos), **Session Replay OFF**, `person_profiles: 'identified_only'`, `mask_personal_data_properties`, e `identify` **solo** con id/rol/empresa — nunca email/teléfono/documento/nombre. Guardado por `VITE_POSTHOG_KEY` (Project API Key `phc_`, **pública**, horneada en el bundle → sin ella es no-op). **Verificado en vivo:** tras login, los eventos llegan a `us.i.posthog.com/i/v0/e/`. Feature flags/funnels quedan disponibles. **Nota de build:** el bundle de prod necesita `VITE_POSTHOG_KEY` (y `VITE_SENTRY_DSN`) en el entorno de build, o esas integraciones quedan desactivadas. **Fase 2:** eventos de negocio (crédito creado, pago registrado) cuando se definan.
- **Tests E2E de la PWA con Playwright (Fase 1: smoke, local).** Se añade el harness de Playwright (`@playwright/test`, `playwright.config.js`, `tests/e2e/`) para probar la PWA Vue de punta a punta — hasta ahora **sin tests JS automatizados** (solo verificación manual). **Fase 1 (smoke):** la pantalla de login renderiza, y el login de los **3 roles** (dueño/supervisor/cobrador) llega al home — en `chromium` Desktop **+ un proyecto Mobile** (Pixel 5), con guard que falla ante **excepciones JS no capturadas** (`pageerror`). El `webServer` levanta `php artisan serve` sobre los assets buildeados; `serviceWorkers: 'block'` evita que el SW precacheado interfiera; los tests corren contra la **empresa demo** (desechable, sembrada). Login estable vía nuevo comando **`credify:e2e-prepare`** (blindado a `local`/`testing`) que fija una contraseña conocida en los usuarios demo — las contraseñas demo reales son aleatorias y rotan a diario. Script `npm run test:e2e`. El CI actual salta la descarga de navegadores (`PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD`); los E2E **no** corren aún en CI. **Requisito local (una vez):** `sudo npx playwright install-deps chromium`. **Fase 2 (pendiente):** flujos de escritura (crear cliente/crédito, registrar/anular pago) + job de E2E en CI. Ver `docs/superpowers/plans/2026-08-13-cloudflare-sentry.md` (Sentry) y este harness.
- **Observabilidad con Sentry (errores backend Laravel + frontend PWA Vue).** Se integra Sentry para capturar excepciones del backend y errores JS de la PWA en producción, con stack traces, release por commit y entorno. Backend: `sentry/sentry-laravel` enganchado en `bootstrap/app.php` (`Integration::handles($exceptions)`), respetando el `dontReport`/`report` existente. Frontend: `@sentry/vue` inicializado en `resources/js/pwa/main.js` **después** del `app.config.errorHandler` (el SDK lo conserva y encadena), guardado por `VITE_SENTRY_DSN`. **PII de fintech blindada:** `send_default_pii=false` + `sql_bindings=false` (no se capturan cuerpos de request ni parámetros SQL) + un `SentryPiiScrubberServiceProvider` que filtra montos/documentos/teléfonos/credenciales — registrado **en código, NO como `before_send` en `config/sentry.php`** (un closure allí rompería `config:cache`/`optimize` en cada deploy). **Source maps** del frontend se suben a Sentry en build (WSL/CI) **solo si hay `SENTRY_AUTH_TOKEN`** y se borran de `public/build` tras subir (nunca se sirven en prod); sin token no se generan `.map`. Sentry queda **desactivado en la suite** (`SENTRY_LARAVEL_DSN` vacío en `phpunit.xml`). Es el **Track A** de `docs/superpowers/plans/2026-08-13-cloudflare-sentry.md`; el Track B (Cloudflare DNS + WAF/rate-limit) queda como despliegue de infra aparte. **Dependencia de despliegue:** cablear `SENTRY_LARAVEL_DSN` + `SENTRY_ENVIRONMENT=production` en el `.env` de prod (como se hizo con Resend); los source maps legibles del frontend requieren `SENTRY_AUTH_TOKEN` + slugs.
- **SEO técnico on-page de la landing (`credifygo.com`).** Open Graph + Twitter Card (share con preview), **canonical**, `robots`/`theme-color`, favicon (el `.ico` estaba vacío → íconos SVG), y **3 bloques JSON-LD** (`Organization`, `SoftwareApplication`, y `FAQPage` con las 5 preguntas → elegible para *rich snippets* de FAQ). Nuevo `public/sitemap.xml` + `robots.txt` referenciándolo. Meta description reescrita keyword-forward (microcrédito, cobranza, Colombia). **Decisión de marca:** se eliminaron los términos **"pagadiario"/"gota a gota"** de toda la página (connotación negativa/ilegal en Colombia) — posicionamiento como software legítimo. **`og:image`**: banner social 1200×630 generado (`public/og-image.png`, dark + emerald, marca + headline). Es el Track 1 de SEO; contenido/keywords y off-page/ads quedan como tracks aparte.
- **Analítica Google Analytics 4 en la landing (Track 2 de SEO — medición).** Snippet `gtag.js` (Measurement ID `G-2S01G726KE`) en el `<head>` de `welcome.blade.php` para medir tráfico y conversión de la landing (visitas, origen, ruta a `/registro`). Complementa la verificación del dominio en Google Search Console + envío del `sitemap.xml`. Solo la landing pública por ahora; instrumentar la PWA autenticada queda como decisión aparte (implica datos de uso de usuarios reales).
- **Contenido pilar para SEO (Track 3 — posicionamiento).** 5 páginas evergreen que capturan demanda de búsqueda de quien administra un negocio de préstamos/microcrédito y la canalizan al trial (`/registro`): `/software-para-prestamos`, `/software-de-cobranza`, `/gestion-de-cartera`, `/como-administrar-negocio-de-prestamos` y `/control-de-prestamos-excel` (~1.100–1.800 palabras c/u, enlazado interno hub&spoke, ejemplos en COP, sin **"pagadiario"/"gota a gota"**). Se **extrajo el "chrome" de la landing** a un layout Blade compartido de 2 niveles (`layouts/public` = head+GA+`@vite`+`<style>`+nav/footer; `layouts/article` = SEO por página + breadcrumb + FAQ + CTA) + componentes `public-nav`/`public-footer`; la landing se refactorizó para consumirlo (render idéntico). `ContentController` provee title/description/faqs y arma el **JSON-LD** (`Article`+`BreadcrumbList`+`FAQPage`) como array PHP → `json_encode` (sin `@verbatim`, evita el bug del parser de Blade). Nuevos: dropdown "Recursos" en nav + columna en footer, sitemap ampliado a 7 URLs, y `ContentPagesTest` (smoke SEO + **guard permanente de marca** que falla el build si aparecen los términos prohibidos). Ver `docs/superpowers/specs/2026-07-30-seo-contenido-pilar-design.md` y su plan. Contenido continuo (blog) y off-page/ads quedan como tracks aparte.
- **Rediseño de los dashboards de inicio de la PWA (Approach C — #43 Fase B).** Los tres dashboards (admin/supervisor/cobrador) comparten un esqueleto — header → quick-actions → carrusel de KPIs deslizable (número completo) → secciones del rol — respetando el **alcance de datos de cada rol**: el P&L del dueño (caja/utilidad/gastos) es solo del admin, el supervisor ve su equipo, el cobrador solo lo suyo. Quick-actions gateadas por permisos reales (el supervisor no registra pagos). Alerta de gastos por aprobar para admin/supervisor. Componentes compartidos nuevos (`KpiCard`, `KpiCarousel`, `QuickActionRow`, `SectionCard`, `DashboardHeader`, `PendingApprovalsAlert`) + guard PHP de alcance por rol (`DashboardDataScopeTest`). Fase 1 sobre datos existentes; tendencias, sparkline y desglose diario por cobrador quedan para la Fase 2. Ver `docs/superpowers/specs/2026-07-23-pwa-dashboards-redesign-design.md` y el plan.
- **Fase 2 del rediseño de dashboards** — utilidad **mensual** real + tendencia MoM (admin), desglose por cobrador + meta de equipo (supervisor), sparkline de cobros 7d + delta hoy-vs-ayer (admin/supervisor); todo cableando servicios existentes (`CashFlowMetricsService`, `CollectionMetricsService`), sin migraciones. Nota: los trends de métricas de *stock* (patrimonio/mora) quedan fuera (requieren snapshots diarios).
- **Snapshots diarios de métricas financieras** (`dashboard_snapshots` + comando `credify:snapshot-dashboard` agendado 23:55) que habilitan la **flecha de tendencia semana-sobre-semana** en los KPIs de *stock* del dashboard admin (Patrimonio, Caja, Mora, Cartera), con polaridad correcta (mora ↓ = bueno). Admin-only; arranca vacío ~7 días (sin backfill de stock).

### Seguridad
- **Bumps de dependencias de build/frontend (Dependabot #208-212, combinados) + `npm audit` a 0.** Patch bumps: `vue` 3.5.39→**3.5.40**, `vite` 8.1.3→**8.1.5**, `@vitejs/plugin-vue` 6.0.6→**6.0.8**, `@tailwindcss/vite` 4.3.1→**4.3.3**, `laravel-vite-plugin` 3.0.0→**3.1.3**. Además `npm audit fix` resolvió una advertencia *high* recién publicada de `brace-expansion` (GHSA-mh99-v99m-4gvg, DoS; el aviso extendió el rango vulnerable para incluir 2.1.2/5.0.7 que se habían fijado en el fix anterior). Todas son dependencias de *build time*/dev (no llegan al bundle del navegador ni corren en prod). Verificado: `npm run build` compila el bundle + service worker sin errores, `npm audit` → **0 vulnerabilidades**. No requiere despliegue de assets (prod no ejecuta `npm`; el bundle servido no cambia funcionalmente hasta el próximo build de una feature).
- **`npm audit` limpio — 4 vulnerabilidades *high* resueltas (solo cadena de build/dev).** `shell-quote` 1.8.4→**1.10.0** (DoS cuadrático en `parse()`, vía `concurrently`; se subió también el `override` de `^1.8.4` a `^1.10.0` para no poder regresar a la versión vulnerable), `brace-expansion` 2.1.1→**2.1.2** y 5.0.6→**5.0.7** (DoS exponencial, vía `workbox-build`→`glob`/`minimatch`), `fast-uri` 3.1.2→**3.1.4** (host confusion, vía `workbox-build`→`ajv`), `concurrently` 9.2.3→**9.2.4**. **Riesgo real bajo:** las cuatro son dependencias de *build time*/dev — **no** se empaquetan en el bundle del navegador ni corren en producción (prod no ejecuta `npm`); el fix protege el entorno de build/CI. Bumps *patch/minor* (no-breaking): `npm audit` → **0 vulnerabilidades** y `npm run build` sigue generando el bundle + service worker sin cambios funcionales. **No requiere despliegue de assets a prod** (los paquetes vulnerables nunca llegaron al output servido).

### Cambiado
- **El listado de créditos de Filament pasa de un filtro "Archivados" engañoso a pestañas por cubeta.** El ternario definía "archivado" como `status IN ('paid','canceled')` — 2 de los 12 estados reales — así que su valor por defecto ("no archivados") dejaba pasar `extended`, `refinanced`, `renewed`, `restructured`, `interest_capitalized`, `defaulted` y `archived`: créditos padre ya sustituidos por un hijo. El mismo préstamo salía dos veces en la lista (en producción, **5 filas de ruido** sobre 171 mostradas). Ahora hay cuatro pestañas con contador: **En curso** (166) = `Credit::scopeActiveLeafCredits` —**exactamente la misma regla que usa la PWA**, para que panel y cartera del cobrador no puedan volver a desalinearse—, **Reemplazados** (6) = tiene crédito hijo, **Cerrados** (383) = estado terminal sin hijo, y **Todos** (555). Las tres cubetas parten el universo sin solapes ni huecos, con un test que lo fija como invariante. Bajo el estado de un crédito reemplazado aparece **"sustituido por NNN"**, que antes no se veía en ninguna parte. De paso se corrigen dos defectos del mismo bloque: el filtro **"Estado" solo ofrecía 5 de los 12 estados** (imposible filtrar por "Extendido") — ahora se alimenta de `Credit::statusOptions()`, derivado de las etiquetas del modelo, fuente única —; y el **callejón sin salida** en el que elegir "Pagado" en ese select devolvía cero resultados porque el ternario lo excluía en paralelo, sin explicación. Nuevos scopes `replacedCredits()` y `closedLeafCredits()` junto a los que ya existían, y `Credit::children` entra al eager load del recurso para que la línea "sustituido por" no cueste una consulta por fila. **Sin migraciones.**

### Corregido
- **El cobrador veía el mismo crédito dos veces en la PWA: el viejo y su hijo (reportado en producción).** Un cobrador de J&A veía dos créditos de la misma clienta; en Filament uno figuraba **Extendido** y el otro **Activo**. El backend no tenía la culpa: `/pwa/credits`, `/pwa/sync/data`, `/pwa/clients/{id}` y el dashboard ya filtran por `ACTIVE_STATUSES` + `whereDoesntHave('children')`, y contra los datos reales la API le devolvía **un solo** crédito. El bug estaba en el caché offline: `syncData()` hacía únicamente `bulkPut` —un upsert— así que un crédito que sale del alcance del cobrador (extendido, refinanciado, renovado, reestructurado, capitalizado, pagado, cancelado o reasignado) dejaba de venir en el payload pero **seguía en IndexedDB congelado con el último estado que alcanzó a sincronizar**, casi siempre `active`. Por eso el filtro por estado de `getCreditsForList` no lo atrapaba: la copia local no decía `extended`, decía `active`. Solo se limpiaba al cerrar sesión o con "Eliminar datos locales", así que un cobrador que nunca cierra sesión acumulaba fantasmas indefinidamente (el crédito reportado llevaba así desde el **2026-09-04**). Ahora el snapshot **reemplaza** el alcance en vez de fusionarlo (`db.applySyncSnapshot`), borrando también las cuotas y los pagos de los créditos purgados. Tres salvaguardas: **(1)** nunca se purga un crédito con escrituras locales sin sincronizar (pagos, visitas o gastos en cola necesitan su snapshot para reintentar y para revertir el saldo si el servidor rechaza), ni el cliente que lo sostiene; **(2)** los pagos que no cuelgan de un crédito purgado se conservan —el servidor solo manda una ventana de días y borrar "lo que no vino" encogería el historial offline—; **(3)** la purga solo se activa si el cuerpo tiene forma de snapshot completo (`meta` + los cuatro arreglos), porque con `|| []` un 200 truncado antes era inofensivo y ahora vaciaría la cartera. Cubierto con un E2E (`tests/e2e/sync-purge.spec.js`) que manipula IndexedDB con la API cruda y verifica que el crédito fuera de alcance desaparece, que el que tiene un pago en cola sobrevive y que los créditos reales no se pierden; **se comprobó que falla sin el arreglo**. El spec corre solo en `chromium`: prueba la capa de datos, no el viewport, y cada login consume presupuesto del rate limit `pwa-login` (5/min por IP+identificador) — duplicarlo en `mobile` hacía fallar la suite en corridas seguidas. Sin cambios de backend.
- **El menú "Probar demo" del hero se veía tapado por el texto del hero (parecía "transparente").** No era transparencia: la fila del CTA (`animate-slide-up`) crea su propio *stacking context* por el `transform` de la animación y no tenía `z-index`, así que las líneas de texto siguientes —también animadas— se pintaban **por encima** del menú abierto (se leía "Sin registro ni tarjeta…" sobre las opciones). Fix: clase `.dropdown-host` (`position: relative; z-index: 30`) en las filas que contienen un dropdown (CTA del hero y CTA final), elevándolas sobre las líneas siguientes y por debajo del nav (`z-50`). Diagnóstico y verificación en el prod real vía `elementsFromPoint` (antes: el `<p>` encima del `.dropdown-menu`; después: el menú encima). Los dropdowns del nav no se afectan (ya viven en el header `z-50`). Solo landing.
- **El menú "Probar demo" de la landing no dejaba elegir rol (se cerraba solo) y no abría en móvil.** Los 4 dropdowns de la landing (CTA "Probar demo" del hero, CTA final, y "Recursos"/"Probar demo" del nav) eran menús **puro CSS por `:hover`** con un gap de 8px (`mt-2`) entre el botón y el menú: al bajar el cursor se cruzaba esa franja que no pertenecía a ninguno → se perdía el `:hover` y el menú se ocultaba antes de poder hacer clic ("se cierra rápido"); y en **touch** (`:hover` inexistente) no abrían en absoluto — bug grave siendo la demo el CTA principal en un público mobile-first. Fix (debugging sistemático — causa raíz: gap de hover + hover-only en touch, no z-index): se convirtieron a **dropdowns por click/tap** con un controlador vanilla JS compartido en `layouts/public.blade.php` (`[data-dropdown]`/`[data-dropdown-toggle]`, toggle de clase `.open`, cierre por click-fuera y Escape, `aria-expanded` mantenido) + CSS plano en el `<style>` del layout (mismo patrón que el acordeón FAQ; sin depender de rebuild de Tailwind). **El fondo/borde/sombra del menú también se movieron a ese CSS plano** (antes el fondo salía del valor arbitrario `bg-[#0b1f17]`, que sólo existe en el bundle si se recompiló con ese código presente → con un `public/build` desactualizado el menú se veía transparente y el texto se solapaba con el fondo). Verificado en vivo: los 4 menús abren/mantienen/cierran, fondo opaco, con las 3 opciones de rol y sin errores de consola. Solo landing pública.
- **El acceso demo (`/demo/{role}`) autenticaba en el servidor pero la PWA rebotaba a `/pwa/login`.** Causa raíz (varias capas): la SPA autentica con **Bearer token** (no con la sesión web), en el boot no hidrataba `user.value` desde la sesión → el router guard (`isAuthenticated = !!user`) mandaba al login; y el `Auth::login()` original hacía que Sanctum resolviera un `TransientToken` (sin `abilities`) en dominios stateful, rompiendo `pwa.access`. **La capa decisiva:** el Service Worker sirve una **shell precacheada** para toda navegación a `/pwa/*` (`NavigationRoute`, online y offline), así que cualquier token inyectado en el HTML server-rendered de `/pwa/home` **nunca llega** al navegador. Fix: el acceso demo **emite un Bearer token PWA** (mismo shape/token que el login real, vía nuevo helper compartido `App\Support\PwaAuthResponse`, del que también pasa a depender `AuthController`) y lo entrega mediante una **página intermedia `demo-enter`** — `/demo/{role}` **no** está en el allowlist del SW, así que se sirve fresca: guarda el bootstrap en **`sessionStorage`** y redirige por cliente a `/pwa/home`, donde `main.js` lo consume en el boot (antes del guard) y arranca autenticada. Se **elimina `Auth::login`** (mata el conflicto del TransientToken). Token corto (1 día); no va en la URL ni en `localStorage`. Cubierto con test (el token del bootstrap autentica `/api/pwa/auth/me`) y verificado en vivo con y sin SW activo. **Requiere `npm run build`** (cambió la SPA).
- **Overlays de la PWA tapados por la barra inferior / el FAB (corrección sistémica de z-index).** En el modal de "Anular pago" (y otros) los botones quedaban **detrás de la barra de navegación** y el FAB verde "+" se colaba encima. Causa raíz (debugging sistemático): la PWA no tenía escala de z-index unificada ni modal compartido — los overlays usaban Tailwind `z-50`, por debajo de la banda propia del `BottomNav` (barra 100, FAB 500), y ningún `safe-area-bottom` reservaba el alto real del nav (~82px). Fix: **escala de z-index por tokens** (`--z-overlay 1000`/`--z-modal 1010`/`--z-toast 1100`/`--z-page-cta 600` + `--pwa-nav-space`) por encima de la banda del nav (que no se toca); nuevo componente compartido **`BaseSheet`** (teleport a `body` + z correcto + padding que reserva el nav + `align` end/center + cierre por click-fuera/Escape + scroll-lock) al que migran los 4 modales (Anular pago, "No paga hoy", Quick actions, Recibo); las 2 barras CTA fijas (`CompanySettingsView` "Guardar", `CreditDetailView` "Registrar Pago") suben a `--z-page-cta` y se posicionan **sobre** el nav; toasts a `--z-toast`; y el FAB se **oculta** en vistas con barra de acción propia (`route.meta.hidePrimaryFab`). El backdrop del modal (z-1000 > FAB 500) cubre el FAB → resuelto también el "+" que se colaba. Ver `docs/superpowers/specs/2026-08-01-pwa-overlay-zindex-design.md` y su plan.
- **El ítem "Escritorio" aparecía dos veces en el menú del panel admin (Filament).** El `AdminPanelProvider` registraba el `Dashboard` **base** de Filament vía `->pages([Dashboard::class])` *además* del `Dashboard` custom (`App\Filament\Pages\Dashboard`, "Escritorio") que ya descubre `->discoverPages()` — dos clases distintas, dos ítems de menú, ambos apuntando a `/admin`. Fix: quitar el registro del base; el custom queda como único dashboard. Bonus: `/admin` ahora resuelve al Dashboard custom (con la lógica de columnas/widgets por rol), no al base por defecto. Verificado en `route:list` (`filament.admin.pages.dashboard → App\Filament\Pages\Dashboard`).
- **Badge de rol en el perfil PWA mostraba "Cobrador" cuando el rol no estaba disponible.** `ProfileView` derivaba la etiqueta de `authStore.userRole`, un getter que defaultea a `'collector'` a propósito (mínimo privilegio para los *router guards* offline) — así que ante un `user.role` ausente el badge afirmaba falsamente "Cobrador". Fix: el badge lee `authStore.user?.role` directo (el rol real) y cae en "Usuario" (neutral) si de verdad no hay rol; el getter `userRole` y su fallback quedan intactos para los guards. (Anotado en el #43.)
- **Modo oscuro roto en varios componentes de la PWA (chart semanal, menú inferior, header de inicio + 4 vistas).** El patrón `:global(html.dark) .selector` en `<style scoped>` se **compilaba a solo `html.dark`** (el descendiente se perdía), así que la regla dark se aplicaba al `<html>` en vez del elemento — por eso esos componentes seguían claros aunque el resto del dashboard fuera oscuro. Fix: envolver el selector completo, `:global(html.dark .selector)`. Verificado en el CSS compilado (`html.dark .wpc`/`.tab-bar`/`.home-header` ahora presentes). Encontrado al probar en vivo el rediseño de dashboards (#43).
- **Campana del header sin función (llevaba a Perfil).** No existe centro de notificaciones, así que la campana era una afordancia falsa. Se cambió por un ícono de **perfil** (acceso real a `/pwa/profile`) y, como Perfil ya no necesita estar en el nav inferior, esa 6ª pestaña se reemplazó por un destino funcional por rol: **admin → Finanzas**, **supervisor → Mora**, **cobrador → Pagos** (quitados del FAB para no duplicar).
- **Dashboard del cobrador recargado.** Se quitó la tarjeta "Cartera Vencida" (su info ya vive en el KPI "Mi vencido", que enlaza a la lista de vencidas) y el sub-bloque "Cobrado hoy" dentro de la Meta del día (redundante con el KPI "Cobrado hoy"); se limpiaron los computeds que quedaron muertos. El home queda enfocado: quick-actions → KPIs → meta del día con progreso → semana.
- **Endurecida la confirmación al *eliminar* un crédito + permiso PWA muerto cableado.** (1) *Eliminar* un crédito (vista y edición) ahora **exige un motivo obligatorio** que se registra en el **log de auditoría** de la aplicación (`Log::warning` con crédito/monto/usuario/motivo) antes de la baja — los `CreditAuditLog` se borran en cascada con el crédito, así que el log de app es el rastro persistente. Complementa el aviso de impacto en caja de #202: eliminar deja de ser un clic pasivo y pasa a ser una decisión consciente y trazable (se mantiene disponible para corregir errores; para desembolsos reales se sigue recomendando *Cancelar*). (2) Se **eliminó el método muerto `RoleAwareQueries::getUserPermissions()`** (sin llamadores) y se **cableó `can_edit_clients` en `AuthController::getPermissionsForRole()`** — el método real que usan `login`/`/auth/me` —; hasta ahora el flag vivía solo en el método muerto y el frontend funcionaba por el *fallback* de rol. Tests: `VoidPaymentTest` (el payload de `/auth/me` expone `can_edit_clients`), `CreditCascadeDeletionFinancialTest` (borrar exige motivo; sin motivo no borra nada). PHPStan + Pint limpios.
- **Eliminar un crédito no limpiaba todo su rastro financiero (crédito #399).** (1) `CreditCascadeDeletionService` borraba Income/Expense/cuotas/auditoría pero **no** los `FinancialOperation`; como la FK es `nullOnDelete`, quedaban **huérfanos** (`source`/`target_credit_id` en NULL) — "zombies" que seguían pesando en métricas de desembolso. (2) Peor: el `DeleteAction` de la **vista y edición** del crédito usaba el *delete* por defecto de Filament (no el servicio de cascada), así que la FK `nullOnDelete` dejaba el **Expense de desembolso DESVINCULADO pero vivo** (`credit_id` NULL) → seguía afectando la **caja** aunque el crédito ya no existiera. **Fix:** el servicio de cascada ahora también borra los `FinancialOperation` del crédito, y **todas** las rutas de borrado (lista, tabla, vista, edición) pasan por el servicio vía `->using()`, garantizando una limpieza completa y consistente. Tests `CreditCascadeDeletionFinancialTest` (el servicio borra el `FinancialOperation`; borrar desde la vista rutea por el servicio). 69 tests financieros/crédito sin regresión; PHPStan + Pint limpios. Además, la **confirmación de borrado** (lista, tabla, vista y edición) ahora **cuantifica el impacto en caja** (cuánto se devuelve al eliminar) y recomienda **Cancelar** para créditos realmente desembolsados — consentimiento informado para no restaurar caja por accidente sin bloquear la corrección de errores. *(Reparado también en prod: se eliminó el `FinancialOperation` huérfano #299 que dejó el borrado de #399.)*
- **La PWA truncaba las listas en 100 (créditos/clientes/cobros) — pérdida silenciosa de datos offline.** `/api/pwa/credits` mostraba solo 100 créditos aunque hubiera más (p. ej. 102): `CreditController::index` tenía un `->limit(100)` fijo, y como la PWA es **offline-first** (pide la lista **sin paginar** y la sincroniza a IndexedDB), el resto **desaparecía** del dispositivo; peor aún, `meta.total` reportaba solo lo devuelto (100), ocultando el truncamiento. (El conteo correcto excluye los créditos padre extendidos/reestructurados vía `whereDoesntHave('children')` — por eso eran 102, no más.) **Fix:** se quitó el tope — el resultado ya está acotado por rol/empresa/estado y por los cupos de plan. El **mismo defecto** estaba en `ClientController` (clientes del cobrador) y `CollectionController` (cuotas por cobrar del día), corregidos igual: un cobrador con >100 clientes o >100 cobros pendientes también perdía datos offline. Test `CreditListNoTruncationTest` (101 créditos → devuelve los 101; `meta.total=101`, rojo→verde). 50 tests PWA/seguridad + PHPStan + Pint en verde. *(Nota: para el rol admin/supervisor «ver todos» de una empresa Enterprise ilimitada el payload podría crecer; si algún día pesa, se paginaría esa vista de admin — no la del cobrador, que es acotada.)*
- **Cuotas `flat_rate` no uniformes cuando el total a cobrar divide exacto (#389).** Al editar #389 (100.000 al 20% = 120.000 en 30 cuotas) salían **29 cuotas de 3.999,99 + 1 de 4.000,29** en vez de **30 × 4.000,00**. No se perdía dinero (la suma daba 120.000 exactos), pero `FlatRateStrategy` allocaba **capital** (100.000/30 = 3.333,33…) e **interés** (20.000/30 = 666,66…) por **separado**, y `Money::allocate()` trunca cada parte a centavos **acumulando todo el residuo en la última cuota**; aunque 120.000/30 = 4.000 exacto, dividir por componentes rompía la uniformidad (29×3.999,99 + spike). **Fix:** ahora se alloca el **TOTAL a cobrar** y el **interés**, y el capital se **deriva = total − interés**. Así el total por cuota queda uniforme cuando divide exacto, **manteniendo** el invariante por fila `total = capital + interés` (#91) y las sumas globales (Σcapital = 100.000, Σinterés = 20.000, Σtotal = 120.000). Aplica también a `flat_rate_projected` (hereda). **No toca datos** — los créditos existentes (como #389, sin pagos → editable) toman los valores correctos al **volver a guardarlos** (regeneran cuotas). Test `installment_totals_are_uniform_when_the_receivable_divides_evenly` en `FlatRateStrategyRowInvariantTest` (rojo→verde) + los 4 casos del invariante #91 intactos; 269 tests del área financiera + PHPStan + Pint en verde.
- **#387 (cont.): al *recalcular cuotas* seguía bloqueando, + `CreditPolicy` alineada con `hasCapitalPayments`.** (a) Tras el fix anterior de `hasPayments()`, editar #387 fallaba con «No se puede recalcular el plan porque el crédito ya tiene pagos registrados»: `CreditCalculator::assertCanRecalculate` usaba el crudo `payments()->exists()` (contaba el pago anulado + la reversa). Ahora usa `CreditRules::hasPayments()` (pagos **efectivos**); como recalcular **borra y recrea** las cuotas, se sigue prohibiendo con cualquier pago **aplicado** (protege los pivotes), pero un crédito con su único pago anulado sí se recalcula — el usuario pudo pasar #387 de 4 cuotas semanales a 30 diarias. (b) **Política alineada:** `delete`/`clone`/`extend`/`cancel` pasan de `hasPayments()` a **`hasCapitalPayments()`**, coincidiendo con sus operaciones (`ensureNoCapitalPaid`), que crean un hijo o hacen baja en cascada y **no** regeneran el plan del padre (permiten pagos solo de interés). **`update` (editar) se mantiene en `hasPayments()` a propósito:** editar regenera el plan → no puede haber **ningún** pago efectivo (anulados/reversas siguen sin contar → #387 editable). Tests en `CreditVoidedPaymentEditableTest` (regenerar a 30 cuotas tras anular; operaciones permitidas con solo-interés pero edición no). 82 tests de crédito/pago + PHPStan + Pint en verde.
- **Un crédito quedaba en SOLO LECTURA tras *anular* su único pago — ya no dejaba editarlo (#387).** `CreditRules::hasPayments()` (base de `Credit::isReadOnly()`, de `CreditPolicy::update/delete/clone/extend/cancel` y de los ~9 campos que el `CreditForm` deshabilita) marcaba "con pagos" con `payments()->exists()`, que **cuenta también el pago anulado (`voided=1`) y su reversa (`payment_method='reversal'`)**. Al anular el único pago **para poder modificar** el crédito (p. ej. pasar de semanal a diario, 4→30 cuotas, mover la primera cuota), la fila anulada + la reversa mantenían `hasPayments()=true` → el crédito quedaba bloqueado para **todo** (editar/borrar/clonar/extender/cancelar), justo lo contrario de lo buscado. **Fix:** `hasPayments()` cuenta solo **pagos efectivos** (`voided=false` **y** `payment_method != 'reversal'`); el capital realmente amortizado lo sigue cubriendo el chequeo de cuotas (`amount_paid`). **No requiere tocar datos** — los créditos afectados (como #387) vuelven a ser editables con el deploy, y al guardar la edición se **regeneran las cuotas** (`handleRecordUpdate` → `regenerateInstallments`) con la nueva periodicidad/cantidad/fecha. Test `CreditVoidedPaymentEditableTest` (reproduce el estado exacto de #387: pago anulado + reversa → editable; un pago real → sigue en solo lectura). 72 tests de crédito/pago + PHPStan + Pint en verde.
- **Auditoría Ronda 3 (2026-07-03) cerrada al 100%: los 6 hallazgos menores de `#129`.** Grab-bag de hardening/consistencia sin impacto crítico hoy:
  - **`CreateIncomes` (Filament) ya no descarta la company elegida por un super_admin.** `mutateFormDataBeforeCreate` sobreescribía `company_id` incondicionalmente con `auth()->user()->company_id` (null para super_admin), aunque `IncomeForm` sí ofrece un selector de company para ese rol. Ahora usa `??=` (mismo patrón que `CreateExpenses`, #69). Test `CreateIncomeCompanyIdTest`.
  - **`CollectorSyncService` (cambios de estado automáticos: reconciliación nocturna, `SyncCreditStatusJob`) gana paridad real con `CreditObserver` (cambios síncronos).** Antes hacía un `delete()`/`appendCredit()` crudos — sin guardar la posición histórica del cliente ni reindexar `sort_order`, y `appendCredit()` no hacía nada si el cobrador no tenía **ninguna** fila de ruta aún. Se extrajo la lógica de `CreditObserver` (respetar historial al insertar, guardar posición + reindexar al salir) a dos métodos públicos nuevos de `CollectorCreditOrderService` (`insertRespectingHistory()`/`removeAndReindex()`), que ahora usan **ambos** — `CreditObserver` y `CollectorSyncService` — como fuente única. Test `CollectorSyncServiceRouteHistoryTest` (3, incluye el caso "cobrador sin ninguna fila de ruta").
  - **Se eliminó el evento `CreditDeleted`** (clase + `event(new CreditDeleted(...))` en `CreditCascadeDeletionService`) en vez de reponerle un listener. El listener original (`LogCreditDeletedListener`) ya se había quitado como código muerto en la limpieza de 2026-06-11 y nunca se repuso; **reponerlo escribiendo en `CreditAuditLog` es arquitectónicamente imposible** — `credit_id` tiene `cascadeOnDelete()` hacia `credits`, así que cualquier fila de auditoría insertada **después** de `$credit->delete()` quedaría eliminada en cascada de inmediato (la FK ya apunta a nada). Se descartó también reutilizar `PwaAuditLog` (sin esa FK) por ser un mal encaje: está sin usar en todo el código y es específico del canal PWA, mientras que el borrado de crédito es una acción exclusiva del panel Filament.
  - **`PartnerInvestmentService::distributeProfits()` ahora usa `Expense::scopeEffective()`** al sumar los egresos del período — antes un egreso **pendiente de aprobación** ya reducía la utilidad distribuible, contradiciendo la regla canónica de caja (`getCashBase`/`getCashPosition`). El método sigue **dormido** (ninguna acción Filament lo cablea hoy); se corrige antes de exponer una UI de distribución. Test `DistributeProfitsEffectiveExpensesTest`.
  - **PWA: el header `X-Device-ID` ahora se envía de verdad.** Nadie escribía la clave `pwa_device_id` en `sessionStorage`; el interceptor de axios la generaba... nunca. Ahora `api.js` genera un id por-pestaña (`crypto.randomUUID()` con fallback) y lo persiste en `sessionStorage` en la primera request. Distinto del `deviceId` persistente de la store de auth (localStorage, usado en login/pagos).
  - **PWA: `db.expireOldData()` compara contra un cutoff en fecha local, no UTC.** Usaba `cutoff.toISOString().slice(0,10)`, que podía desalinear la ventana de expiración ±1 día cerca de medianoche frente a `payment_date`/`paid_date` (guardados en fecha local). Ahora usa `getDaysAgoDateString()` del util de fechas del proyecto.
  - **PWA: cerrada la ventana TOCTOU del guard `syncing`** en `syncPendingPayments`/`syncPendingVisits`/`syncPendingExpenses`. El flag se fijaba **después** del primer `await` (lectura de la cola en IndexedDB), así que dos disparos concurrentes (p.ej. el listener `online` + un registro manual) podían pasar ambos el guard antes de que cualquiera marcara `syncing=true` y disparar el mismo batch dos veces (la idempotencia del servidor evitaba el doble cobro, pero desperdiciaba la petición). Ahora `syncing.value = true` se fija como primera línea tras el guard inicial. (Ya no: desde el arreglo de la cola offline, PWA-001, esas tres funciones no existen; un solo motor con un único candado sube las tres colas.)
  - Sin infraestructura de tests JS en el repo (no hay Vitest/Jest); los 3 fixes de PWA se verificaron con `npm run build` (sin errores) y revisión manual. Suite PHP completa: **507 passed**. PHPStan 0, Pint OK. **Cierra #129 — Ronda 3 al 100%.**

### Cambiado
- **Se eliminaron del modelo `Plan` y del esquema los flags de plan *sin implementar*** (`has_api_access`, `has_advanced_reports`, `has_multi_branch`, `has_custom_branding`, `has_priority_support`). **Ninguno se hacía cumplir en código**: los accesores `Subscription::hasApiAccess()`/`hasCustomBranding()` estaban definidos pero **nunca se invocaban** para gatear nada, no hay API de cliente ni modelo de sucursales, y la **marca (color + logo) está disponible para todas las empresas** — así que mostrarlos como diferenciadores de plan era vender humo (ya se habían quitado de la grilla del sitio en #168). Migración **reversible** `drop_unimplemented_plan_feature_flags` (drop de las 5 columnas; `down()` las recrea con sus defaults). Se limpiaron: los toggles del `PlanForm` (Filament), la lista de *Funcionalidades* en `CompanySettings`, la `PlanFactory`, los accesores de `Subscription` y 2 entradas obsoletas del baseline de PHPStan. **Se mantiene `has_pwa_access`** (representa el acceso a la PWA, real y universal; usado en el setup de tests). **Requiere `migrate --force` en el deploy.** 30 tests relacionados en verde; PHPStan + Pint limpios.
- **La landing promociona los 3 planes (Básico $99k · Profesional $249k · Enterprise $599k).** Antes el sitio mostraba **un solo plan** (`is_public` solo en Profesional, migración #142) con una tarjeta única y el título "Un solo plan". Ahora: migración `make_all_plans_public` (reversible) deja los 3 con `is_public=true`; la ruta `/` pasa `Plan::getPublicPlans()` (grilla) + `Plan::getTrialPlan()` (nuevo accesor, Profesional, para el CTA/copy del trial); la sección de precios de `welcome.blade.php` se reescribió a una **grilla de 3** (`@foreach`) con Profesional destacado (borde teal + badge). **Decisión de producto:** *trial fijo + planes informativos* — todos los CTA arrancan la prueba de 14 días en Profesional (`TrialRegistrationService::TRIAL_PLAN_SLUG`, sin cambios); el plan real se ajusta al suscribirse. Se corrigió que `TrialRegistrationController`/`SubscriptionLockController` usaban `getPublicPlans()->first()` (que con 3 públicos devolvería **Básico** por sort_order) → ahora `getTrialPlan()`. **Requiere rebuild de frontend** (Tailwind escanea la vista compilada; se añadió `md:grid-cols-3` etc.) → `public/build` reconstruido en dev y enviado a prod por el runbook. Test `landing_page_shows_the_three_public_plans`. Suite completa: **467 passed**.

### Corregido
- **Pricing veraz: la grilla ya no anuncia funciones sin implementar (#168) + #167 verificado.** La grilla de precios (que una sesión previa volvió *data-driven*) mostraba 5 «funciones» por plan (reportes avanzados, tu marca, multi-sucursal, API, soporte prioritario) leídas de los flags del plan — pero **ninguna se hace cumplir en código**: los accesores `hasApiAccess`/`hasCustomBranding`/`hasFeature` están **definidos pero nunca se invocan** para gatear nada, no hay modelo de sucursales ni API de cliente, y la **marca —color y logo— está disponible para TODAS las empresas** (config de empresa en PWA/panel, sin gatear). Anunciarlas era **vender humo**. Ahora la grilla muestra **solo los límites reales y gateados** (usuarios/cobradores/créditos/clientes + **desembolso mensual**, auditados en #149/#169/#187), la **marca pasa a «incluido en todos los planes»** (es universal) y se quitaron reportes avanzados/multi-sucursal/API/soporte de la grilla. **#167 verificado:** el copy ya aclara que la prueba de 14 días es del plan **Profesional** (con todas sus funciones) sin importar el CTA que se pulse, y que al terminar se elige el plan — decisión de producto *trial fijo* (no se arrastra el plan del CTA). Test `pricing_grid_shows_only_real_enforced_limits_and_is_truthful` (límites reales visibles, `assertDontSee` de las funciones no reales, marca universal, copy del trial). Solo Blade (sin rebuild de frontend). **Cierra #167 y #168.**
- **TOCTOU en los límites por conteo — endurecido en el panel (#169) + #170 verificado.** El punto de mayor riesgo (**sync offline de la PWA**, que replica varias creaciones a la vez) ya era atómico vía `PlanLimitService::runGuardedCreate` (transacción + `lockForUpdate` sobre la suscripción, con el assert **dentro** del closure). Faltaba el panel Filament: `beforeCreate` hacía el assert **sin lock** y Filament creaba el registro después → dos altas concurrentes de la misma empresa podían pasar ambas el `count < max` y dejarla 1 sobre el tope. Ahora `CreateUser`/`CreateClient`/`CreateCredit` (+ `EditUser`) fijan `$hasDatabaseTransactions = true` (Filament ya envuelve `beforeCreate → handleRecordCreation → saveRelationships` en **una** transacción) y toman `lockPlanForUpdate()` (bloqueo de la fila de la suscripción) **antes** del assert → un segundo request espera al commit del primero y recuenta; el lock abarca `saveRelationships`, así que también cubre el cupo de **cobradores** (que se cuenta tras sincronizar el rol). **#170 verificado:** la guarda de `EditUser::beforeSave` (bloquea el alta neta de cobrador al editar) ya estaba implementada y con test (`EditUserPlanLimitTest`, 2 casos) — confirmado en verde. Regresión de límites (24 tests) verde; Pint + PHPStan limpios. **Cierra #169 y #170.** *(La serialización por concurrencia es una garantía estructural —transacción + `lockForUpdate`—, no unit-testeable; los tests cubren que no se rompió el enforcement secuencial ni la creación.)*
- **`max_monthly_volume` ahora cuenta el dinero nuevo de refinanciar/renovar/clonar (#187).** El medidor `Company::currentMonthDisbursedVolume()` sumaba solo el `amount` de los créditos **raíz** del mes y excluía a **todos** los hijos (#172) — pero **refinanciar** (capital adicional) y **renovar** (diferencia) desembolsan **caja nueva real** en un crédito hijo, que así **se escapaba** del tope (Básico 20M / Profesional 100M no son ilimitados). Ahora es **aditivo**: `amount` de raíces + `FinancialOperation.cash_out` de operaciones sobre **hijos** no cancelados. El invariante del modelo `cash_out = credit_amount − compensated_amount` garantiza que solo cuenta **lo nuevo** (extender/reestructurar → `cash_out 0` → no inflan; refinanciar → adicional; renovar → diferencia; clon → monto). Es **aditivo** (solo puede contar de más caja real, nunca de menos) → no altera el conteo de raíces existente ni sus tests. Test `CurrentMonthDisbursedVolumeTest`. **Cierra #187.** *Pendiente (decisión de producto):* refinanciar/renovar aún **no se pre-bloquean** al superar el volumen (sí lo hacen crédito nuevo y clon); su caja ya cuenta para gatear los siguientes desembolsos — pre-bloquearlas bloquearía rollovers de retención.
- **Auditoría de *enforcement* de límites de plan (verificación end-to-end, valores en vivo desde BD).** Se verificó que cada tope (usuarios, cobradores, clientes, créditos activos, volumen mensual) se **lee en vivo de la BD** (`Subscription::getLimit` → override `custom_limits` → columna del plan; `0` = ilimitado) y se aplica en **todas** las rutas de creación (panel Filament Create/Edit + API PWA `store`), **bloqueando y notificando al admin** al llegar al tope. Dos agentes mapearon exhaustivamente cada punto de creación de `User`/`Client`/`Credit` (Filament, PWA, servicios, jobs, seeders). **Resultados:** (a) **[corregido] el clon «Duplicar»** (`CreditOperationService::cloneAsNewEditableCredit`) era un crédito **nuevo con desembolso real** que **no** cierra al padre (suma +1 activo) y **se colaba sin control** → podía superar `max_active_credits`/`max_monthly_volume`; ahora se gatea en el *chokepoint* con `assertCanCreateCredit` (bloquea dentro de la transacción → no crea nada; las acciones «Duplicar» de `ViewCredit`/`EditCredit` capturan y notifican). (b) **[sin gaps]** la ruta de **sync offline** de la PWA no crea clientes/créditos (solo pagos/visitas/gastos); la PWA **no** crea usuarios ni asigna rol cobrador; las transformaciones (extender/reestructurar/renovar/refinanciar) van *ungated* por diseño (cierran al padre → neto cero activos, #172); trial y seeders son exentos intencionales. (c) **[surfaced → #187]** refinanciar/renovar desembolsan **dinero nuevo** (capital adicional / diferencia) que, al ir en un hijo, **no cuenta** en el medidor de `max_monthly_volume` (#172 excluye hijos) → **corregido** con un medidor aditivo (ver #187, abajo). **Tests:** `PlanLimitDbDrivenTest` (tope en vivo desde BD: editar el plan cambia el cupo, `custom_limits` override, `0`=ilimitado), `CreateUserPlanLimitTest` (bloqueo + notificación al crear), `CloneCreditPlanLimitTest` (el clon respeta el cupo). *Valores reales confirmados:* Básico 2u·1c·50créd·100cli·20M, Profesional 10·5·200·500·100M, Enterprise ilimitado.
- **Autogestión de suscripción — #171 era un *falso positivo* (ya estaba implementada).** La auditoría marcó «la empresa solo puede crear una solicitud `upgrade_plan` hardcodeada, sin UI para los demás tipos». En realidad, la página **`CompanySettings` › pestaña Suscripción** ya muestra **plan + uso** (barras de usuarios/créditos/clientes/volumen con `Ilimitado` y colores por %) y su acción **«Solicitar Cambio»** crea solicitudes **tipadas** (`getRequestTypes()`: upgrade/downgrade/más usuarios/cobradores/créditos/clientes/volumen/facturación/otro) con `requested_plan_id`/`requested_value`/comentarios, dispara el evento `created` que **notifica a los super_admins**, y respeta el anti-duplicado (`canCreateRequest`). El único flujo hardcodeado a `upgrade_plan` es `/suscripcion` (**reactivación** post-expiración), de un solo propósito por diseño. Se añadió la cobertura que faltaba: `SelfServeSubscriptionRequestTest` (2: aumento de límite tipado + upgrade a un plan específico). **Cierra #171** — solo test + verificación, sin cambio de runtime. *(Lección de auditoría: verificar los hallazgos contra el código; este agente solo miró el flujo de `/suscripcion`.)*
- **Race TOCTOU en los límites por conteo (#169, MEDIO).** `assertCanX()` contaba y el controlador creaba en **pasos separados, sin transacción ni lock**: dos requests concurrentes (típico de la **sincronización offline** de la PWA, que descarga varias creaciones a la vez) pasaban ambos el chequeo `count < max` y dejaban a la empresa 1+ sobre el tope. Nuevo `PlanLimitService::runGuardedCreate($company, $op)` que corre **chequeo + creación** en una transacción con `lockForUpdate` sobre la fila de la suscripción → **serializa por empresa** (el segundo request espera al commit del primero y vuelve a contar, ya viendo el registro nuevo). Cableado en `ClientController@store` y `CreditController@store` de la PWA (el panel Filament ya envuelve el *create* en transacción y es de un solo usuario secuencial). Tests: atomicidad (el create se revierte si el assert falla dentro del guard) + enforcement intacto. Auditoría R4. **Cierra #169.**
- **El volumen mensual del plan contaba créditos de transformación y cancelados (#172, MEDIO).** El límite `max_monthly_volume` sumaba el `amount` de **todos** los créditos creados en el mes, incluidos los **hijos de transformación** (extender/refinanciar/reestructurar/renovar crean un hijo con `created_at` fresco y `amount` = saldo heredado, **sin mover caja**) y los **cancelados** → inflaba el volumen y podía **bloquear desembolsos legítimos**. Ahora `Company::currentMonthDisbursedVolume()` (que usan `canDisburse` y `getUsageStats`) cuenta solo **desembolsos reales**: créditos raíz (`parent_credit_id` nulo) no cancelados; el original sí cuenta una vez, el hijo (rollover) no. Test `monthly_volume_excludes_transformation_children_and_canceled`. Auditoría R4. **Cierra #172.**
- **`EditUser` evadía el cupo del plan (#170, MEDIO).** La guarda `EnforcesPlanLimits` solo corría en `CreateUser`, así que **editar un usuario existente y asignarle el rol `collector`** (o moverlo a otra empresa) superaba `max_collectors`/`max_users` sin control. Ahora `EditUser::beforeSave()` aplica el cupo, pero solo al **alta neta**: asignar `collector` a quien no lo era, o mover el usuario a otra empresa (donde cuenta como +1 usuario y +1 cobrador si aplica). Editar a un cobrador que ya lo era —o cambiarle el nombre en la misma empresa— **no** se bloquea aunque el cupo esté lleno (el conteo se evalúa antes de sincronizar el rol del form, por eso se compara contra el estado persistido). Tests `EditUserPlanLimitTest` (2: bloquea el alta neta sobre el tope, permite editar al cobrador existente). Auditoría R4. **Cierra #170.**
- **Veracidad del pricing: funciones por plan visibles y prueba clara (#168 · #167, MEDIO).** La grilla de precios solo mostraba **4 límites numéricos** y ocultaba las diferencias reales de *features* entre planes; el pie afirmaba que **«todos incluyen … reportes de cartera/mora/recaudo y tu marca»** — **falso para Básico** (`has_advanced_reports`/`has_custom_branding` = false); y cada tarjeta tenía un CTA «Empieza gratis» que sugería iniciar la prueba de **ese** plan cuando el trial **siempre es Profesional** (#167). Ahora: (a) cada tarjeta muestra **5 funciones data-driven** (`Reportes avanzados`, `Tu marca`, `Multi-sucursal`, `Acceso API`, `Soporte prioritario`) con ✓ si el plan la tiene o **tachada/gris** si no — leídas de los flags reales, así editar el plan en `/admin` se refleja aquí (verificado: Básico 5 tachadas, Profesional 3, Enterprise 0); (b) el pie solo promete lo **genuinamente universal** (app offline, comprobante digital, control de caja/cartera); (c) el copy deja claro que **la prueba de 14 días es del plan Profesional** y que **eliges tu plan al terminar**, sin importar qué tarjeta pulses. El **bot de ventas** repetía el mismo claim falso en su nodo de precios → reencuadrado igual (funciones avanzadas *varían por plan*; la prueba es del plan Profesional), sin rebuild (JS inline). **Requiere rebuild de frontend** (utilidades `line-through`/`decoration-slate-300` nuevas). Tests `pricing_grid_is_truthful_features_visible_and_clear_trial` + guard en `the_sales_bot_pricing_is_data_driven`. Auditoría R4. **Cierra #168 y #167.**
- **Ventana de bloqueo indebido en el período de gracia (#164).** `Subscription::createTrial()` no fija `grace_period_ends_at` (queda NULL) y `scopeCurrent` exigía `ends_at>=now OR grace_period_ends_at>=now`; cuando `ends_at` pasaba (a cualquier hora), el gate negaba acceso **hasta que el cron de las 00:00** materializaba `grace_period_ends_at` → el cliente quedaba bloqueado hasta ~24h **pese a tener 7 días de gracia** (admin a `/suscripcion`, cobradores 403 en PWA), con riesgo de solicitar/pagar reactivación innecesaria. **Fix:** `scopeCurrent` computa la gracia **en vivo** (`orWhere('ends_at','>=', now()->subDays(GRACE_PERIOD_DAYS))`), acotado por `scopeActive()` (status IN active/trial/grace), así que una suscripción ya expirada/cancelada no se cuela. Test `SubscriptionGraceWindowTest` (5 casos, incl. regresión de expirada reciente). Auditoría R4. **Cierra #164.**
- **«Aprobar»/«Marcar Implementado» una solicitud ahora ACTIVA de verdad la suscripción (#163, ALTO).** El workflow `pending → approved → implemented` **fingía** cerrar el ciclo: `SubscriptionRequest::approve()`/`markImplemented()` solo cambiaban el estado de la *solicitud* — **jamás tocaban ninguna `Subscription`**, así que el super_admin tenía que **editar la suscripción a mano** (status→Activa, empujar `ends_at`) o el cliente seguía bloqueado (`activate()` era código muerto). Nuevo **`SubscriptionActivationService`**: `approveAndActivate()` (acción «Aprobar») e `implementAndActivate()` (acción «Marcar Implementado») aprueban/implementan **y activan en una transacción** — `status=active`, extienden `ends_at` un ciclo de facturación (desde el mayor entre ahora y el `ends_at` vigente, sin perder tiempo restante), aplican `requested_plan_id`, limpian gracia/cancelación; el `save()` invalida la caché de acceso → el panel/PWA se desbloquean al instante. Cableado en `ViewSubscriptionRequest` (**ambas** acciones, no solo «Aprobar» — el otro punto de entrada del hallazgo). Tests `SubscriptionActivationTest` (4: reactiva la existente sin duplicar + desbloquea el gate, extiende el ciclo, aplica el plan pedido, implementar-activa una ya aprobada). Auditoría R4. **Cierra #163.**
- **Copy honesto también en `/registro` y `/suscripcion` (la auditoría «sin humo» de #150 solo había tocado la landing).** Ambas pantallas seguían prometiendo en su lista de beneficios **«App del cobrador con GPS»** (rastreo que la PWA **no** hace — solo geoetiqueta cada pago *best-effort*) y **«Recibos por WhatsApp y notificaciones SMS»** (funciones **inexistentes**, ya retiradas de la landing). Reencuadradas a lo real: *«app offline (sin internet)»*, *«comprobante digital de cada cobro, con hora y ubicación, para compartir o imprimir»* y *«reportes de cartera, mora y recaudo · tu marca»*. Solo Blade (sin rebuild). **Contacto WhatsApp retirado de `/suscripcion`** (decisión de producto): se quitó el enlace `wa.me` de esa pantalla y queda **solo el botón self-serve «Solicitar mi suscripción»** (que crea el `SubscriptionRequest` y notifica a los super_admins), alineado con #150 («todo WhatsApp al chat»; esa pantalla logueada no tiene chat). Guard en `SubscriptionLockoutTest` (`assertDontSee('wa.me')`).
- **Chat de ventas y píldora del registro ahora *data-driven* (dejaron de mostrar precios/planes hardcodeados).** El bot (`partials/sales-bot.blade.php`) seguía con el guion de **un solo plan**: decía «Un solo plan… **Profesional $249.000/mes**… 10 usuarios, 5 cobradores, 200 créditos, 500 clientes» — **desactualizado desde #156** (ya hay 3 planes públicos) y **congelado** aunque se editara el precio en `/admin`. Ahora el bot **inyecta los planes desde el panel** (`@json($sbPlans)` con `name`/`formatted_price`/`badge_text` + `TRIAL_DAYS` desde `trial_days`): el nodo *precios* lista los 3 planes con su precio y badge reales, y los días de prueba son dinámicos en `start`/`trial`/`howbuy`. Se mantiene **XSS-safe** (los datos del plan se escapan con un helper antes del `innerHTML`, aunque solo los edita el super_admin). También la píldora «14 días» de `/registro` (`trial-register.blade.php`) pasa a `{{ $plan?->trial_days }}`. **Sin rebuild de frontend** (el bot es CSS/JS inline). Verificado en navegador: abrir el chat → «¿Cuánto cuesta?» muestra «Tenemos **3 planes**… • Básico $99.000 · Profesional $249.000 (Más Popular) · Enterprise $599.000 (Todo Incluido)». *Auditoría:* la grilla de precios, `/registro` y `/suscripcion` **ya** eran data-driven (leen `$plan->formatted_price`/`getLimitDisplay`); el **chat era el único punto estático**.
- **Columnas monetarias de `credits`/`installments` ampliadas a `DECIMAL(12,2)` (#128).** `credits.amount` y las 5 columnas de dinero de `installments` (`principal_amount`, `interest_amount`, `total_amount`, `balance_due`, `amount_paid`) estaban en `decimal(10,2)` (tope 99.999.999,99), mientras el resto del ledger (payments, `payment_installment.applied_amount`, `forgiven_amount`, expenses, incomes, partners, PAR) ya usa `(12,2)`. `PaymentMaterializer` suma `applied_amount`(12,2) en `installments.amount_paid`(10,2) → un valor ≥100M truncaría (MySQL no estricto) o lanzaría (estricto). Migración `widen_money_columns_to_12_2` (reversible; `->change()` preserva comments/default por regla L13; `interest_rate` (5,2) **no** se toca, es porcentaje). Ampliar precisión no pierde datos. Test `MoneyColumnPrecisionTest` (round-trip de 150M > tope viejo). **Cierra #128.**

### Añadido
- **Anular pagos desde la PWA (solo admin).** En el detalle del crédito, el historial de pagos (`PaymentHistory`) gana un botón **«Anular»** (visible **solo** para `admin` y **con conexión**) con modal de **motivo obligatorio**; llama a `POST /api/pwa/payments/{id}/void`, que reutiliza el **mismo mecanismo** del panel `/admin` (`PaymentManager::canDeletePayment` + `deletePayment` → **reversa con auditoría**, nunca borrado físico). Acotado por empresa (pago ajeno → **404**); supervisor/cobrador → **403**; reglas de bloqueo por `canDeletePayment` (ya anulado / crédito cerrado / auditoría crítica → **422**). Tras anular, el pago **desaparece** del historial y el saldo/cuotas se refrescan (**solo-online**). De paso, `/credits/{id}/payments` **excluye los asientos de reversión** (`payment_method != 'reversal'`) para que un pago anulado (desde admin o PWA) no aparezca como monto **negativo**. Tests `VoidPaymentTest` (7: admin anula + reversa; supervisor/cobrador 403; cross-company 404; motivo requerido; ya-anulado 422; historial sin reversas); **60 tests PWA** en verde, PHPStan + Pint limpios, `npm run build` OK. Diseño en `docs/superpowers/`.
- **Editar clientes desde la PWA.** Nueva vista `/pwa/clients/:id/edit` (botón «Editar» en el detalle del cliente) y endpoint `PUT /api/pwa/clients/{id}`: edita nombre, cédula, teléfono, email y **dirección primaria** (upsert: actualiza la existente o crea una). Permiso admin/supervisor/cobrador **por empresa** con la **misma visibilidad que el detalle** (un cobrador no edita clientes fuera de su alcance → 404, cross-company → 404); **cédula única por empresa ignorando al propio cliente**; la ruta lleva `throttle:pwa-write` + `whereNumber('id')` como sus hermanas. **Solo-online** (igual que crear) y al guardar **actualiza IndexedDB** (`db.clients`, incluida la dirección formateada que muestra el detalle offline) para consistencia inmediata. Se extrajo un `ClientForm.vue` **compartido** por crear y editar; `show()` del cliente ahora devuelve también la dirección **estructurada** (`state`/`city`/`address_line_1`) para precargar el formulario. Tests `UpdateClientTest` (6: admin/cobrador editan + upsert de dirección, fuera de alcance 404, cross-company 404, unicidad de cédula, `show()` estructurado); 53 tests PWA en verde, PHPStan + Pint limpios, `npm run build` OK. Diseño en `docs/superpowers/specs/` y `docs/superpowers/plans/`.
- **Anular pagos directamente desde la tabla de pagos en la vista del crédito (solo admin).** La tabla de pagos de `ViewCredit` (pestaña *Pagos*, `payments-table.blade.php`) era **solo lectura**: para anular un pago había que ir a `/admin/payments`. Ahora cada pago **efectivo** muestra un botón **«Anular»** que abre un modal pidiendo el **motivo** y reutiliza el mismo mecanismo seguro que la tabla global (`PaymentManager::canDeletePayment` + `deletePayment`). «Anular» = **REVERSAR** (asiento compensatorio + `voided=1`, con auditoría), **nunca** un DELETE físico del registro contable. Efecto útil: al anular el único pago, el crédito vuelve a ser editable (payoff de #387). **Restringido a administradores** (`admin`/`super_admin`) en **dos capas**: el botón se oculta a supervisores/cobradores (la columna entera desaparece) y la acción lleva `->visible()` + guard de rol dentro de `->action()` (defensa en profundidad; Filament rechaza montar una acción no visible). **Aislamiento multi-tenant:** el pago se resuelve vía `$credit->payments()` (no `Payment::find`), así un id ajeno al crédito visto nunca resuelve. Respeta el diseño *solo-infolist* del recurso (sin RelationManager). Tests `CreditViewVoidPaymentTest` (3: admin anula end-to-end + botón renderizado; guard cruzado entre créditos; cobrador no ve el botón). Solo PHP/Blade (sin rebuild de frontend; el botón usa un `<style>` embebido). PHPStan + Pint limpios.
- **Avisos in-app de vencimiento de prueba/suscripción (#166, ALTO).** Antes **no había ningún aviso**: el cliente se enteraba de que su prueba/suscripción venció cuando ya estaba **bloqueado** → mataba la conversión del trial y aceleraba el churn. Nuevo comando **`subscriptions:notify-expiring`** (programado a diario 08:00, tras `update-status`) que a **T-3 y T-1 días** notifica a los **admins de la empresa** por el canal `database` (campana del panel, **sin depender de SMTP** — prod no lo tiene) vía `SubscriptionExpiringNotification`, con contexto *prueba* / *renovación* / *gracia* (esta última medida contra `grace_period_ends_at`). Empareja por **fecha** (no hora) → un aviso por umbral. Tests `SubscriptionExpiryNoticeTest` (3: trial a T-3 avisa, activa a T-1 con contexto renovación, fuera de umbral no avisa). **Cierra #166**; los recordatorios por **email** quedan pendientes de configurar SMTP en prod.
- **Registro de pago manual en la suscripción + modelo de *billing* offline documentado (#165, ALTO).** Credify **no tiene pasarela de pago** (decisión de producto): el cobro es offline y se refleja a mano. Los campos `amount_paid`/`payment_reference` **ya existían** en `subscriptions` pero **no estaban en ningún formulario** → no había dónde registrar cuánto/cómo pagó la empresa. Ahora: (a) el form de **Suscripción** (Filament) expone **Monto pagado** + **Referencia de pago** (informativos — no cobran nada; el acceso lo controla `status`); (b) los modales **«Aprobar»/«Marcar Implementado»** de una solicitud capturan el pago y lo persisten al activar (`SubscriptionActivationService` ahora acepta `amountPaid`/`paymentReference`) → el pago se registra **en el mismo paso** que se reactiva (#163); (c) el modelo manual/offline queda **documentado** en `CLAUDE.md` (SaaS Layer). Test `activation_records_the_manual_payment`. **Cierra #165**; la pasarela/facturas DIAN quedan como trabajo futuro.
- **Gestión de planes editable desde `/admin` — los precios ya no son fijos.** Antes los planes (precios, límites, funcionalidades, visibilidad) solo se cambiaban por migración/seeder: **no existía recurso Filament de planes**, así que los precios eran de facto fijos. Nuevo **`PlanResource`** (`app/Filament/Resources/Plans/*`, **solo super_admin** vía `PlanPolicy` = `SuperAdminOnly`, grupo *Administración*) para crear/editar planes, con los **precios (mensual + anual)** en la primera sección. La página de precios del sitio es *data-driven*, así que cambiar un precio aquí se refleja **de inmediato** en la landing (verificado end-to-end: al editar el precio del plan visible, la landing pasó de `$99.000` a `$123.456` y volvió al revertir). Salvaguardas: el **`slug` queda bloqueado en edición** (lo referencia el sistema — `getDefaultPlan('basic')`, registro de trial — cambiarlo rompería esas rutas) y **no se expone borrado** (un plan con suscripciones no debe eliminarse: dejaría suscripciones sin plan → límites ilimitados, #125; para retirarlo del sitio basta con desactivar «Visible en el sitio»/«Activo»). Recurso PHP puro → **sin migración ni assets**, se aplica con `optimize` + reload php-fpm. Tests `PlanResourceTest` (3: listar, cambiar precio, slug bloqueado en edición). Complementa el trabajo de la otra sesión que promociona los **3 planes** en el sitio (#156).
- **Bot de ventas + captura de leads + landing veraz (sin humo).** El "chat" del sitio (antes solo un botón flotante de WhatsApp) es ahora un **asistente de ventas guiado** (`partials/sales-bot.blade.php`): flujo determinista por botones que explica **cómo empezar la prueba** (→ `/registro`) y **cómo comprar**, y **captura el lead pidiendo correo + celular** (ventas contacta por esos medios; WhatsApp aún sin definir, por eso el copy es neutral). Autocontenido (CSS `.sb-` + JS inline, **sin rebuild de frontend**), accesible, y **XSS-safe** (el contenido dinámico del servidor se pinta con `textContent`, nunca `innerHTML`; jamás refleja lo que teclea el usuario). **Todo enlace de contacto del sitio abre el chat** — se eliminaron los enlaces `wa.me` (hero, precios, banda final) y se reemplazaron por CTAs `data-open-chat="lead"`. **Backend de leads:** `POST /leads` (`LeadController` + `StoreLeadRequest`, `throttle:5,1`) → modelo `Lead`. **Endurecido contra inyección/XSS:** correo `email:rfc`, celular con patrón `^[0-9()+\-\s]{7,25}$`, nombre/mensaje rechazan `<>` (defensa en profundidad; la salida además se escapa en Blade/Filament); SQLi cubierto por Eloquent (binding). `LeadResource` **solo super_admin** (`LeadPolicy` = `SuperAdminOnly`) con acciones rápidas **Correo** (`mailto:`) y WhatsApp, cambio de estado y badge de leads nuevos. **Landing reescrita para decir la verdad** (auditada contra el código): se quitó el **GPS de seguimiento/mapa en vivo** (la PWA solo geoetiqueta cada pago *best-effort* si el cobrador lo autoriza; no hay mapa ni rastreo de ruta → se reencuadró a "cada cobro con nombre, hora y ubicación del dispositivo, trazable"), los **recibos por WhatsApp/SMS automáticos** (no existen → se reencuadró a "**comprobante digital** que el cobrador comparte o imprime", que sí existe vía `PaymentReceipt.vue`), la **mora automática** (solo se marcan cuotas vencidas + indicadores PAR → reencuadrado a control de mora/cartera) y un texto que **contradecía el modo offline** ("nada queda en el dispositivo"). Verificado en navegador (bot con correo+celular, CTAs abren el chat, hero veraz). Tests `LeadCaptureTest` (5, incluye rechazo de XSS/inyección). **Prueba social saneada:** se quitó el rating inventado (4.9/5 de dueños satisfechos), las estadísticas ficticias (+500 créditos, 0% cuadres con error) y el testimonio atribuido ("Jorge Hernández, Bucaramanga"); se reemplazaron por *highlights* verificables (sin internet, sin contratos, 14 días de prueba, 3 roles) y una sección de "promesa" sin cita ficticia.
- **Enforcement de los límites del plan (bloqueo estricto en panel + PWA).** Los límites por plan (`max_users`, `max_collectors`, `max_active_credits`, `max_clients`, `max_monthly_volume`) estaban **definidos y con toda la lógica lista** (`Plan::allowsMore*`, `Subscription::canAdd*`, `Company::canAddUser/canCreateCredit`) pero **nunca se invocaban al crear** — solo se usaban para *mostrar* uso en dashboards. Resultado: empresas muy por encima de su tope (p.ej. company #1 Básico con **144/100 clientes, 3/2 usuarios, ~90/50 créditos activos**). Ahora se aplican de verdad: nuevo `PlanLimitService` (`assertCanAddUser/Collector/Client`, `assertCanCreateCredit` con conteo **y** volumen mensual) que lanza `PlanLimitExceededException` con mensaje accionable; cableado en los puntos de creación reales — **panel Filament** (`CreateUser`/`CreateClient`/`CreateCredit` vía el trait `EnforcesPlanLimits` → notificación + halt) y **API PWA** (`ClientController@store`, `CreditController@store` → **422** con el mensaje). Se añadieron los primitivos que faltaban (`Company::canAddClient/canAddCollector/canDisburse`). Los flujos **internos/sistémicos** (registro de trial, renovaciones, seeders) **NO** se gatean. Además, la tabla de **Compañías** ahora muestra el **Plan** vigente y una columna **"Uso del plan"** (`Usuarios 3/2 · Clientes 144/100 · Créditos 90/50`, en rojo si algún cupo está superado). **Decisión de producto (confirmada): bloqueo estricto sin grandfathering** — una empresa ya sobre el tope (como #1) **no podrá crear** nuevos usuarios/clientes/créditos hasta reducir o ampliar su plan. Tests nuevos `PlanLimitServiceTest` (7) + `PwaPlanLimitTest` (2). Suite completa: **461 passed**.
- **Auto-registro público con prueba de 14 días — Fase 1 (backend).** Nueva capacidad para que una empresa se dé de alta sola desde la web y opere de inmediato en modo prueba, sin intervención manual. **Decisiones de producto (confirmadas):** se ofrece **un único plan público, "Profesional"** ($249k, "Más Popular", límites medios); el trial dura **14 días**; al expirar → **bloqueo + solicitud de suscripción** (MVP, Fase 3); email **se activa ya y se verifica después** (verificación dirigida en Fase 3, no `MustVerifyEmail` global para no romper usuarios existentes). **Entregado en esta fase:** (a) migración que deja **solo Profesional como plan público** (`basic`/`enterprise` → `is_public=false`; idempotente y reversible); (b) `TrialRegistrationService` que, en **una transacción**, aprovisiona todo lo que una empresa nueva necesita — `Company` (activa, interés flat-rate), `User` admin (rol `admin`, `company_id` explícito porque el registro es sin sesión), `CompanyFinancialSettings` por defecto y una `Subscription` en estado `trial` sobre el plan Profesional con `trial_ends_at`/`ends_at` a +14 días; (c) `POST /registro` (`RegisterTrialRequest` con `unique:users,email` = 1 trial por email + `throttle:5,1` anti-abuso) que registra, autentica y redirige a `/admin`. El acceso durante la prueba lo concede `hasActiveSubscription()` (status `trial` + `ends_at` futuro). Test nuevo `TrialRegistrationTest` (3 casos: aprovisionamiento correcto, alta+login vía ruta, email duplicado rechazado). Suite completa: **432 passed**. **Fases siguientes:** landing/form público (Fase 2), pantalla de bloqueo post-trial + verificación de email dirigida (Fase 3). Avanza [#125](https://github.com/jdredondo/credify/issues/125).
- **Auto-registro público con prueba de 14 días — Fase 2 (landing + formulario).** La cara pública de la Fase 1: (a) nueva sección **"Precios"** en la landing (`welcome.blade.php`), *data-driven* desde el plan público — muestra Profesional ($249.000/mes, ahorro anual ~17%, badge "Más Popular") con sus límites reales (10 usuarios · 5 cobradores · 200 créditos · 500 clientes) y features; (b) las CTAs principales pasan de solo-WhatsApp a **self-serve** ("Empieza gratis · 14 días" en navbar, hero y banda final → `/registro`; se conserva WhatsApp como opción de venta asistida); (c) nueva página `GET /registro` (`auth/trial-register.blade.php`) — formulario accesible (labels, `aria-invalid`/`aria-describedby`, inputs de 16px anti-zoom iOS, foco visible) con resumen del plan, que consume el `POST /registro` de la Fase 1. La ruta `/` ahora pasa `Plan::getPublicPlans()->first()` a la vista; el controlador gana `create()` (redirige a `/admin` si ya hay sesión). *Detalle:* el `body` global es oscuro (tema PWA) y gana por estar sin `@layer`, así que la página de registro envuelve su contenido en un wrapper `min-h-screen` con degradado claro para cubrir todo el alto. Verificado en navegador (desktop + móvil): landing, precios y formulario. 3 tests nuevos en `TrialRegistrationTest` (landing muestra el plan, form renderiza, sesión activa → redirect). Avanza [#125](https://github.com/jdredondo/credify/issues/125). **Sigue Fase 3:** bloqueo post-trial + verificación de email dirigida.
- **Auto-registro público con prueba de 14 días — Fase 3 (bloqueo post-trial → solicitud de suscripción).** Al expirar la prueba, antes el `Login` de Filament mostraba "Suscripción Expirada" y **no dejaba entrar** (callejón sin salida). Ahora: (a) nuevo middleware `EnsureActiveSubscription` en el panel que **redirige al admin sin suscripción a `/suscripcion`** en vez del 403 seco; (b) el `Login` valida credenciales **primero** (corrige una fuga: antes revelaba que un email existía y su suscripción estaba expirada aun con contraseña errónea) y, si el admin no tiene suscripción, lo **loguea y lo lleva a `/suscripcion`**; (c) nueva pantalla `GET /suscripcion` (`subscription/locked.blade.php`) con el plan y un botón **"Solicitar mi suscripción"** → `POST /suscripcion/solicitar` crea un `SubscriptionRequest` (tipo `upgrade_plan`, 1 pendiente por empresa) que **notifica a los super_admins** (canal database, sin depender de SMTP) para que la revisen/activen; incluye estados "solicitud enviada"/"en revisión", WhatsApp y cerrar sesión. **Decisión de arquitectura:** el gate de suscripción se movió de `canAccessPanel` (que ahora solo comprueba rol) al middleware, porque la **prioridad de middleware de Laravel** obliga a `Authenticate` a correr antes que cualquier middleware propio — un chequeo en `canAccessPanel` produciría un 403 imposible de interceptar. La garantía de acceso es idéntica (el middleware corre en **todas** las rutas del panel) y `FilamentPanelAuthorizationTest` se actualizó para verificarlo en esa capa. **De paso** se reparó `SubscriptionRequestNotification` (y su gemelo en `ViewCredit`): usaban `Filament\Notifications\Actions\Action`, **inexistente en Filament 5** (unificado en `Filament\Actions\Action`) — un crash latente que estaba *baselined* en PHPStan en vez de arreglado (se quitaron ambas entradas del baseline). **Aplazado a un paso futuro:** la verificación de email (el mailer está en driver `log` y prod no tiene SMTP). Test nuevo `SubscriptionLockoutTest` (7 casos). Suite completa: **442 passed**. **Cierra la parte MVP de** [#125](https://github.com/jdredondo/credify/issues/125).

### Cambiado
- **Color primario del panel `/admin` (Filament): del ámbar/naranja por defecto al verde de marca (emerald).** Filament venía con `Color::Amber` — chocaba con la identidad verde de Credify. Se cambió a **`Color::Emerald`** (`#10b981`) en `AdminPanelProvider`, así botones, enlaces, ítem de menú activo, toggles, focus rings y notificaciones usan el verde de la marca. Filament calcula la paleta `primary-50..950` **en runtime** (la inyecta como CSS vars OKLCH), así que **no requiere rebuild de frontend** — basta con `optimize` + reload de php-fpm en el deploy. Los colores semánticos del tema propio (`theme.css`: success/danger/warning/info de badges y timelines) son independientes y no se tocan. Verificado en navegador (`/admin/login`: botón "Entrar" y focus ring en emerald). *(Nota de marca: el primario elegido fue emerald, el acento del hero; el token `--cg-primary` de la PWA sigue en teal `#14b8a6`.)*
- **Rediseño del *hero* de la landing — de un lavado gris pálido a un "escenario" oscuro tipo *aurora* (marca emerald).** El fondo `slate-50 → teal-50` se sentía plano y desangelado; ahora el hero es una superficie oscura (`hero-dark`, degradado emerald-950 con destellos *aurora* emerald/teal/cyan difuminados + una textura de rejilla sutil enmascarada) que hace **flotar y brillar** las tarjetas-mockup de la app (ahora blancas sólidas con sombra emerald, `hero-card`; el badge "sin internet" pasa a vidrio oscuro `hero-glass-pill`). Titular en blanco con acento en degradado menta→cyan (`gradient-text-hero`), CTA principal emerald con *glow*, CTA secundario en vidrio y microcopy con contraste AA. Guiado por el skill `ui-ux-pro-max` (patrón **Aurora UI** + glassmorphism sobre fondo oscuro, que la guía marca como *best-for* de secciones hero premium). Se añadió además un guard `@media (prefers-reduced-motion: reduce)` que apaga todas las animaciones del hero (accesibilidad). Solo cambia `welcome.blade.php` (markup + `<style>` embebido); requiere **rebuild de frontend** (utilidades Tailwind nuevas: `blur-[120px]`, tonos emerald, etc., que se generan al escanear la vista compilada) → `public/build` reconstruido en dev y enviado a prod por el runbook. Verificado en navegador: **desktop** con las tres tarjetas flotando sobre el fondo oscuro, **móvil** (sin scroll horizontal), sin errores de servidor.

### Corregido
- **Performance de la landing: 404s de fuentes eliminados, bug de escape en un patrón SVG y caché de assets.** Auditando `credifygo.com` para PageSpeed (móvil): (a) la webfont **Inter** (`@fontsource` en `resources/css/app.css`) apuntaba a `./files/*.woff2` que **no se emitían al build** → **404 en cada carga**; como la app ya caía al *system font stack*, se quitó el `@import` (menos requests, menos latencia de fuente, `app.css` **−6 KB**; quien tenga Inter instalado localmente lo sigue viendo). (b) Un overlay decorativo de la sección "Por qué Credify GO!" usaba `background-image: url(\"data:image/svg+xml…\")` con **comillas escapadas al estilo JS** (`\"`), inválidas en un atributo HTML → cerraban el atributo antes de tiempo y el navegador pedía `/%EF%BF%BD` (**404**); corregido con `&quot;`. (c) Los **assets hasheados de Vite** (`/build/assets/…-<hash>.ext`) no traían política de caché → se añadió en `public/.htaccess` (`<FilesMatch>` + `mod_headers`, no `<If>` para no arriesgar `AllowOverride`) `Cache-Control: public, max-age=31536000, immutable`, **sin** tocar el service worker (`pwa-sw.js`) ni los manifests (siguen revalidándose). Resultado verificado en navegador: la landing carga **un solo recurso** (el CSS, gzip ~18 KB) **sin requests fallidos**. Requiere rebuild de `public/build` (nuevo hash `app-ss0b7h_6.css`).
- **UX de los recursos de Gestión de Créditos y Finanzas.** Auditoría con el mismo criterio que la de Administración. **[grave] Operaciones Financieras**: `recordTitleAttribute` apuntaba a `'name'`, una **columna inexistente** → encabezado/breadcrumb vacío; ahora `getRecordTitle()` da "Tipo #N" (con el `credit_renewal` que faltaba en el mapa de tipos); la tabla suma columna `#` (id). **Créditos** y **Pagos**: el título era el `id` desnudo → ahora "Crédito #N · Cliente" / "Pago #N · Cliente". **Socios (form)**: acentos faltantes (Cédula de ciudadanía/extranjería, Número, Correo electrónico, Teléfono, participación, dinámico, "automáticamente según la proporción"). *Nota:* Clientes, Ingresos y Egresos ya estaban correctos (formato de dinero `money('COP')`, badges de categoría/estado humanizados) — no se tocaron; y el reporte del audit se verificó (marcó falsamente las columnas de dinero de Operaciones Financieras como sin formatear cuando **sí** usaban `money('COP')`). Test nuevo `CreditosFinanzasUxTest` (títulos humanos, modelos en memoria).
- **UX de los recursos de Administración (Suscripciones, Solicitudes, Compañías, Usuarios).** (a) **[grave] El encabezado/breadcrumb de una suscripción mostraba el JSON crudo del plan** — `SubscriptionResource::$recordTitleAttribute` apuntaba a `'plan'`, que es una **relación** (Filament serializaba el modelo Plan); ahora `getRecordTitle()` devuelve un título humano ("Empresa · Plan X"). (b) La **tabla de Suscripciones** apuntaba la columna "Plan" a la relación (→ JSON) y encima formateaba valores de *ciclo de facturación*; tenía `created_at` **duplicada** y mostraba `is_active` (inerte) en vez del `status` real → ahora: `plan.name`, columna propia de **facturación** (Mensual/…), **estado** como badge con color + filtro, y sin duplicados. (c) `updated_at` estaba etiquetada **"Creado"** en Compañías y Usuarios → "Actualizado". (d) **Solicitudes**: el título era el `id` desnudo → "Solicitud #N · Empresa"; y el ítem de menú "Solicitudes **De** Suscripción" → "Solicitudes de suscripción". (e) **Usuarios**: la columna Rol mostraba el slug crudo (`admin`) → etiquetas legibles. (f) **Compañías**: nueva columna+campo **Estado** (Activa/Suspendida/Inactiva) y se fijó el `navigationIcon`. Test nuevo `AdminResourcesUxTest` (título humano, no-JSON, listado renderiza con las columnas nuevas).
- **No se podía editar al super_admin (p.ej. cambiar su contraseña): fallaba con `validation.required` en el campo Compañía.** El `UserForm` marcaba `company_id` como `->required()` **incondicional**, pero el super_admin global no pertenece a ninguna empresa (`company_id = null`, invariante de `User::isSuperAdmin()`). Fix: la compañía solo es obligatoria si el rol seleccionado **no** es `super_admin`. **Además, el mensaje salía como la clave cruda `validation.required`** porque, con `APP_LOCALE=es`/`APP_FALLBACK_LOCALE=es`, **no existía ningún archivo `lang/`** → el validador de Laravel devolvía la clave en vez del texto (afectaba a **todos** los formularios, incluido el registro público del trial). Se añadió `lang/es/validation.php` (traducción estándar) → ahora los errores se muestran en español ("El campo Compañía Asignada es obligatorio."). Test nuevo `UserFormSuperAdminTest` (3 casos: editar super_admin sin empresa, compañía sigue obligatoria para no-super, mensajes en español).
- **`/admin/subscription-requests` reventaba con 500 (`Class Filament\Tables\Actions\ViewAction not found`).** La tabla `SubscriptionRequestsTable` importaba `ViewAction`/`ActionGroup`/`Action` del namespace **`Filament\Tables\Actions\*`** (Filament 4), **unificado en Filament 5 a `Filament\Actions\*`**. Igual que el crash de `SubscriptionRequestNotification`, estaba **silenciado en el baseline de PHPStan** en vez de arreglado (3 entradas `class.notFound` removidas). La Fase 3 hizo esta pantalla relevante — es donde el super_admin revisa/aprueba las solicitudes que genera el bloqueo post-trial —, así que sin el fix el super_admin no podía cerrar el ciclo. Fix: los 3 imports a `Filament\Actions\*` (el resto de la tabla ya era compatible con Filament 5; la página `ViewSubscriptionRequest` hermana ya usaba el namespace correcto). Test nuevo `SubscriptionRequestsTableTest` (renderiza el listado con un registro → construye las row actions donde vivía el bug).

### Desplegado a prod (2026-07-16)
- **`cd885f4` (#193) — cierre de la Auditoría Ronda 3 (#129, los 6 hallazgos BAJO restantes).** `git pull origin main` (`6371ffc` → `cd885f4`, sin migración ni cambios de composer/npm) + **frontend reconstruido en dev y enviado** (`public/build`: `main-ChRSkQ-6.js` → **`main-BP-638OD.js`**, PWA `sync.js`/`api.js`/`db/index.js`; `app.css`/`app.js` del panel admin sin cambios; se conservó el hash viejo del PWA para los *service workers* ya instalados) + `sudo cp` a `public/build` (con `sudo`, no `cp` a secas — el directorio ya era propiedad de `www-data` del deploy anterior) + `optimize` + `chown -R www-data:www-data storage/framework/views` + reload `php8.3-fpm`. Ventana breve de mantenimiento (`artisan down/up`). Verificado en `https://credifygo.com`: `/`, `/admin/login`, `/pwa` → **200**; asset PWA nuevo y viejo → **200**; `pwa-sw.js` con el hash nuevo; sin errores nuevos en `laravel.log`; queue worker (supervisor) sin interrupción. **Cierra** [#129](https://github.com/jdredondo/credify/issues/129) — Auditoría Ronda 3 al 100% en prod.

### Desplegado a prod (2026-07-13)
- **`1292fcf` — los 3 planes en el sitio (#156) + gestión de planes con precios editables (#159).** Deploy coordinado (se mergeó #156 y luego #159 encima): `git pull` (`cd9be02` → `1292fcf`) + **`sudo -u www-data php artisan migrate --force`** (`make_all_plans_public` → los 3 planes con `is_public=true`) + **frontend reconstruido en dev y enviado** (`app-ss0b7h_6` → **`app-jDno0zu-`**, con las clases de la grilla de 3 planes; se conservó el css viejo para los *service workers* ya instalados) + `optimize` + reload php-fpm. Verificado en `https://credifygo.com`: landing **200** con los 3 planes y sus precios ($99.000/$249.000/$599.000), asset nuevo y viejo → **200**, `/admin/login` y `/registro` → **200**. *Al ponerse al día:* la otra sesión ya había desplegado **#153** (migración decimal, corrida en prod) y **#154** (perf: 404 de fuentes + cache headers) — prod ya los tenía; no se rehízo nada.
- **Chat de ventas + píldora del registro *data-driven*** (ver *Corregido*): **backend-only** (`git pull` + `optimize` + reload php-fpm; sin migración ni assets — el bot es inline). El chat dejó de mostrar el precio/plan hardcodeado.

### Desplegado a prod (2026-07-09)
- **`dce17f8` (#151) — rediseño del *hero* de la landing (aurora oscuro, arriba).** `git pull origin main` + **frontend reconstruido en dev y enviado** (`public/build`: `app-HEkJPI4c.css` → **`app-DMDq_JoQ.css`** + `pwa-sw.js` con el hash nuevo; se **conservó el `app.css` viejo** en el servidor para que los *service workers* ya instalados no queden con precache 404) + `sudo -u www-data php artisan optimize`. Sin migración/composer/queue. **Gotcha documentado** (ver `CLAUDE.md` › *Frontend / PWA*): Tailwind v4 solo escanea las **vistas compiladas** (`@source storage/framework/views`), así que hay que **compilar la vista de la landing** (basta abrir la página en `credifygo.local`, que la compila como `www-data`) **antes** de `npm run build`, o sus utilidades arbitrarias (`blur-[120px]`, etc.) **no se generan**. Verificado en prod: `/` → **200** con el markup nuevo (`hero-dark`, `animate-aurora`), el asset nuevo y el viejo → **200**, y `pwa-sw.js` apuntando al hash nuevo.
- **`172b8d8` (#150) — bot de ventas + captura de leads + landing veraz (arriba).** `git pull origin main` + **`sudo -u www-data php artisan migrate --force`** (tabla `leads`) + `optimize` + `reload php8.3-fpm`. **Sin rebuild de frontend** (el bot usa CSS `.sb-` + JS inline). Verificado en prod: landing **200** con el markup del bot y **cero enlaces `wa.me`**; CSRF activo (`POST /leads` sin token → **419**; con token → **201**); flujo de lead end-to-end **201** (lead de prueba borrado). El **CI había fallado por el flaky conocido** `UserFormSuperAdminTest` (pasa aislado); re-run → verde (**466 passed**) antes del merge vía REST.

### Desplegado a prod (2026-07-06)
- **Redeploy backend-only a `f07b6af`** (enforcement de límites del plan, arriba): `git pull` → `optimize` → `reload php8.3-fpm`. **Hallazgo:** en **prod** la company #1 ya estaba en **Enterprise (plan_id=3, límites 0 = ilimitado)**, no en Básico — el "Básico sobre el tope" era el estado de la **BD de dev** (`credifygo.local`). Por eso el bloqueo estricto **no congela** a #1 en prod (Enterprise → `isUnlimited`). Se alineó dev #1 a Enterprise para que el local coincida con prod. Verificado: `/admin/companies` (invitado) → **302**, `/admin/login`, `/`, `/pwa` → **200** (sin 500).
- **Redeploy backend-only a `36642d8`** (UX de Créditos/Finanzas, arriba): `git pull` → `optimize` → `reload php8.3-fpm`. Verificado: `/admin/credits` (invitado) → **302**, `/` → **200** (sin 500).
- **Redeploy backend-only a `bec737a`** (UX de recursos de Administración, arriba): `git pull` → `optimize` → `reload php8.3-fpm`. Verificado: `/admin/subscriptions` (invitado) → **302**, `/admin/login` y `/` → **200** (sin 500).
- **Redeploy backend-only a `a996b06`** (super_admin editable sin empresa + `lang/es/validation.php`, arriba): `git pull` → `optimize` → `reload php8.3-fpm`. Verificado en prod: lang file presente, `UserForm` con el required condicional, y `/admin/users` (invitado) → **302**, `/admin/login` y `/` → **200** (sin 500).
- **Redeploy backend-only a `3035589`** (fix de `SubscriptionRequestsTable`, arriba): `git pull` → `sudo -u www-data php artisan optimize` → `reload php8.3-fpm` (sin migración/composer/npm/queue). Verificado: `/admin/subscription-requests` (invitado) → **302** a login (sin 500), `/admin/login` y `/registro` → **200**. El render autenticado de la tabla queda cubierto por `SubscriptionRequestsTableTest`.
- Prod actualizado de `6356aec` (#124) a **`cfc1271`** vía `git pull origin main`. El salto incluye, además del **trial completo (#142 F1 + #143 F2 + #144 F3)**, lo acumulado sin desplegar: **#139** (PWA: rollback del estado `paid` al rechazo de sync, #126), **#140** (PWA: eliminación del Background Sync muerto, #127) y **#141** (Filament `SubscriptionForm` persiste `plan_id`/`billing_cycle`/`status`, #125). Migración `make_professional_only_public_plan` ejecutada (**1 plan público: Profesional**). **Frontend reconstruido en dev y enviado** (`public/build`, `app-HEkJPI4c.css`, incluye las páginas nuevas + los cambios PWA de #126/#127) por el runbook (Vite no buildea en la VM); swap atómico owned www-data. `optimize` + reload php-fpm (opcache) + `queue:restart` (para el `SubscriptionRequestNotification` corregido, que es `ShouldQueue`). Verificado en `https://credifygo.com`: `/`, `/registro`, `/admin/login`, `/pwa` y el asset hasheado → **200**; `/suscripcion` (invitado) → **302** a login; la landing renderiza el plan Profesional ($249.000). El **auto-registro público con trial está en vivo.**

## [2026-07-03]

### Corregido
- **Autorización del panel Filament: cierre del *default-allow* (#124, seguridad ALTO).** `canAccessPanel` solo comprobaba suscripción activa (cualquier rol alcanzaba `/admin`) y los recursos solo overrideaban `canViewAny` sin policy → `canView($record)`/`canCreate`/`canEdit` caían a **default-allow**: un admin de empresa podía auto-provisionarse una suscripción, ver solicitudes de **otra empresa** (`withoutGlobalScopes` → IDOR cross-tenant) y un supervisor crear un admin (escalación). **Fix:** (a) `canAccessPanel` exige rol `super_admin`/`admin` (supervisores/cobradores/clientes usan la PWA); (b) policies nuevas — `Subscription`/`SubscriptionRequest`/`Company` solo super_admin, `User` scoped a la propia empresa del admin (el borrado individual excluye al propio usuario); (c) quitado `withoutGlobalScopes()` de `SubscriptionRequestResource` (el super ve todo por el bypass de `MultiTenantScope`). *Nota técnica:* Laravel omite la policy cuando el método de la ability no existe (`is_callable` corre antes de `before`), por eso cada policy **declara todas sus abilities** (trait `SuperAdminOnly`). Test nuevo `FilamentPanelAuthorizationTest` (7 casos). **Cierra #124.** (PR #138)
- **Filament: `SubscriptionForm` persiste `plan_id`/`billing_cycle`/`status` reales (#125).** Usaba `Select::make('plan')` — atributo **inexistente** en `Subscription` (columnas reales: `plan_id` FK + `billing_cycle`) → la selección se descartaba al guardar → `plan_id=null` → `getLimit()` caía a plan null → **todos los límites resolvían a ilimitado**; y el `Toggle('is_active')` era inerte (el acceso lo maneja `status`). **Fix:** `Select('plan_id')->relationship('plan','name')` + `Select('billing_cycle')` + `Select('status')` (fuera el toggle engañoso). Test nuevo `SubscriptionFormTest` (Livewire — primer test de form Filament del repo). **Cierra #125.** (PR #141)
- **PWA: el estado optimista `paid` ahora se revierte al rechazo permanente del sync (#126, MEDIO-ALTO).** Al encolar un pago offline, `deductCreditBalance` hace un update optimista: si el pago liquida el crédito, marca `status='paid'` + cuotas pagadas. Cuando el servidor lo rechazaba **permanentemente**, el rollback solo restauraba `remaining_balance` (no `status` ni cuotas) y `syncData()` solo corría `if (successCount>0)` → el crédito quedaba `paid` con saldo>0 y **desaparecía de la ruta del cobrador** (que filtra `ACTIVE_STATUSES`). **Fix:** `queuePayment` guarda `_original_status`; el rollback restaura **estado y saldo**; y se fuerza `syncData()` también tras rechazos permanentes para reconciliar las cuotas desde la verdad del servidor. Verificado con `npm run build` (la PWA no tiene harness de tests JS — ver watchlist). **Cierra #126.** (PR #139)
- **PWA: eliminado el Background Sync del Service Worker por ser código muerto (#127).** El SW tenía un handler `sync` (tag `sync-payments`) + `syncPaymentsInBackground()`, pero **nadie registraba el tag** (`sync.register`) → nunca disparaba. Además era **más débil y redundante** que el sync de foreground: no reconciliaba el estado local del crédito ni manejaba rechazos/rollback (habría reintroducido #126 por esa vía), necesitaba una ventana abierta para el token, y la Background Sync API no existe en **iOS** (plataforma clave). Se eliminaron el handler, `syncPaymentsInBackground`, los helpers de IndexedDB del SW y el plumbing `GET_TOKEN`/`SYNC_COMPLETE` de `main.js`; se conservan SKIP_WAITING/GET_VERSION/push. La cola se sigue sincronizando en foreground (`online`/`visibilitychange`) con la lógica correcta (incl. el rollback de #126). Verificado con `npm run build`. **Cierra #127.** (PR #140)
- **FlatRate: `total_amount` por cuota ahora es exactamente `principal_amount + interest_amount` (#91).** `FlatRateStrategy` allocaba capital, interés y total **por separado** con `allocate()`; cada array absorbía su residuo de redondeo en la última cuota, pero los residuos no coincidían → en cuotas intermedias `principal + interés ≠ total` (hasta ±1¢). El total global del crédito cuadraba, pero rompía el invariante por fila y corrompía `principal_paid`/`interest_paid` (prorratean sobre `total_amount`), reportes de utilidad y los snapshots de rescheduler/restructure. **Fix:** derivar el total por fila = capital + interés (se deja de allocar el total por separado); el global sigue cuadrando (`Σ capital = monto`, `Σ interés = interés total`). `FlatRateProjectedStrategy` hereda el fix (llama `parent::calculate`). Test nuevo `FlatRateStrategyRowInvariantTest` (4 casos × ambas estrategias). Suite completa: **421 passed**. **Cierra #91.** (PR #120)

### Mantenimiento
- **Refresh de dependencias del grupo `laravel` (Dependabot #115).** El PR venía titulado "bump laravel/boost 2.4.11", pero el grupo arrastró un update **dentro del major**: `laravel/framework` v13.15 → **v13.18.1**, `symfony/*` (console, http-kernel, translation, mailer, …) → 7.4.14, `nesbot/carbon` 3.12 → 3.13, `guzzlehttp/guzzle` 7.12 → 7.13 (+ `psr7` 2.12.3), `league/flysystem` 3.34 → 3.35, `ramsey/uuid` 4.9.3, `brick/math` 0.14.8 → **0.18.0** (transitivo vía carbon/uuid), `laravel/prompts` 0.3.21, `laravel/mcp` 0.8.2, y `laravel/boost` 2.4.11 (dev). Rebasado sobre `main` actual y verificado: **suite completa 421 passed** + PHPStan 0 + Pint. **Desplegado a prod el 2026-07-03** vía `composer install`. (PR #115)

### Desplegado a prod (2026-07-03)
- Prod actualizado a `main` (`1af8589`): **#91** (FlatRate, code-only) + **#115** (refresh de deps del grupo laravel vía `git pull` + `composer install --optimize-autoloader`) + **frontend reconstruido en dev y enviado a prod** (`public/build` con Vite 8.1 / dexie 4.4.4, swap atómico owned www-data) — **cierra #119**. El frontend **no se buildea en la VM** (Vite 8/Rolldown falla con `npm ci`); se buildea en dev/CI y se envía el artefacto (ver runbook de prod). Verificado: `admin/login`, `home`, `pwa` y un asset hasheado del nuevo manifest → **200**.

## [2026-07-02]

### Añadido
- **Protocolo de trabajo multi-máquina / multi-sesión en [`CLAUDE.md`](CLAUDE.md).** Nueva sección "Working across machines / sessions": Git + GitHub es el **único** punto de sincronización (la memoria local de Claude Code `~/.claude/**` **no** se comparte entre máquinas; el contexto durable vive en `CLAUDE.md` + `CHANGELOG.md`, versionados). Reglas: reclamar el issue antes de empezar, ramificar siempre desde `origin/main` fresco, PRs pequeños que se mergean rápido, repartir por dominio para no editar los mismos ficheros, y cada PR actualiza este CHANGELOG. Ambas sesiones leen `CLAUDE.md` al arrancar, así que el protocolo aplica a las dos. **Setup crítico:** cada sesión debe usar su **propio `git worktree`** o clon — dos sesiones sobre el mismo checkout comparten `HEAD`/índice y colisionan (lección de 2026-07-02).

### Cambiado
- **#97 (hardening) mergeado tras revisión independiente** (PR [#107](https://github.com/jdredondo/credify/pull/107)): scope de empresa en las reglas `exists` de `credit_id`/`installment_id`, `throttle:pwa-write` en los endpoints de escritura que faltaban, y `Money::cents()` consistente con `toString()`. Se descartaron 3 sub-ítems con justificación (MultiTenantScope-en-consola es intencional; `PaymentReverser` **debe** permitir reabrir un crédito `paid` al revertir el pago final; `CollectionVisitController::today` ya está scoped por `company_id`). **Cierra #97.**
- **Auditado #108 vs #95/#96:** el fix de #90 (PR #108) **solo** tocó `ExtendCreditOperation`; **no** resolvió #95 (renovación: `validateForRenewal` sigue con `assertNoCapitalPaid` + `hasPayments`) ni #96 (`ExtendWithInterestOperation:209-210` usa floats nativos; `cash_out` del restructure custom; `hasCapitalPayments` "REGLA TEMPORAL"). Ambos siguen **abiertos**.

### Mantenimiento
- **Batch de dependencias de Dependabot (bajo riesgo, consolidado en un PR).** Se agruparon los bumps para evitar la cascada de rebases (los npm comparten `package-lock.json`; los composer comparten `composer.lock`). npm: `dexie` 4.0.10→4.4.4 (prod, PWA), `axios`→1.18.1, `postcss`→8.5.16, `vite` 8.0→8.1.0 (dev). Composer: `spatie/laravel-permission` 8.0→8.1.0 (auth), `laravel/sail`→1.63.0 (dev), `leandrocfe/filament-apex-charts` 5.1.0→5.1.1 (prod) + asset republicado (`ApexCharts` 5.10.4→5.15.2 vía `vendor:publish --force`). GitHub Actions: `checkout` 6→7, `cache` 5→6, `deploy-pages` 4→5. Todos CI-verde individualmente + **build + suite completa 410 passed** en conjunto. **Excluido a propósito:** `concurrently` 9→10 ([#99](https://github.com/jdredondo/credify/issues/99)) — v10 exige Node ≥22 (dev/prod en Node 20); es dev-only (`composer dev`). Supersede los PRs de Dependabot #98/#101/#102/#103/#104/#105/#106/#110/#111/#112. (PR #114)

### Corregido
- **Renovación y transformaciones de crédito (#95, #96).** Con la regla de negocio de #90 como referencia (operación sin dinero nuevo ⇒ sin interés extra; movimientos de caja explícitos):
  - **#95 — La renovación ya permite créditos con capital pagado.** `validateForRenewal`/`canRenew` exigían *tener pagos* pero a la vez *prohibían capital pagado* (`assertNoCapitalPaid`) → contradicción que hacía la renovación **inalcanzable** en su caso típico (cliente que ya amortizó y quiere un préstamo nuevo). Se quitó `assertNoCapitalPaid` de la renovación (igual que `extendWithInterest`); sigue exigiendo pagos previos y monto nuevo ≥ saldo.
  - **#96.1 — `cash_out` falso en el restructure custom.** `RestructureCreditWithCustomInstallmentsAction` registraba `cash_out = plan_total − saldo` (positivo) en `financial_operations` para una operación **sin desembolso** (se contradecía con el `cashOut: 0` que ya retorna). Ahora `cash_out = 0`; deja de distorsionar las métricas de caja.
  - **#96.2 — `ExtendWithInterestOperation` sin floats.** `calculateAdditionalInterest` hacía `$balance->value() * ($rate/100) * $periods` en float nativo (violaba "no floats"); ahora `$balance->percentage($rate)->multiply($periods)` en BC Math (mismo número, sin error de redondeo).
  - **#96.3 — Detección precisa de capital pagado.** `CreditRules::hasCapitalPayments` solo marcaba capital si una cuota estaba 100% saldada; ahora detecta también los abonos parciales que superan la porción de interés (`amount_paid > interest_amount`). Efecto: extensión/refinanciación/reestructuración **simples** rechazan correctamente créditos con capital parcial pagado (deben usar extend-with-interest).
  - Test nuevo `RenewalCapitalPolicyTest` (4). Suite completa: **414 passed**. **Cierra #95 y #96.** (PR #116)
- **Refinance y restructure permiten créditos con capital pagado (#117, follow-up de #96.3).** #96.3 endureció `hasCapitalPayments` y con ello bloqueó también refinance/restructure para créditos con capital pagado, **sin alternativa** (a diferencia de la extensión, que tiene extend-with-interest). Hallazgo: refinance y restructure **ya aceptan una tasa** (`newInterestRate`) — no hacía falta crear operaciones nuevas. Se quitó `assertNoCapitalPaid` de `validateForRefinance`/`validateForRestructure` + `canRefinance`/`canRestructure` (consistente con renovación #95 y extend-with-interest: refinance es un préstamo nuevo; restructure deja al usuario elegir la tasa). Además, `RestructureCreditOperation` sin tasa explícita ahora crea el hijo a **tasa 0** (redistribuye el saldo sin recargar interés) en vez de heredar la original — cerrando el mismo hueco de anatocismo de #90. Test nuevo `RefinanceRestructureCapitalTest` (3). Suite completa: **417 passed**. **Cierra #117.** (PR #118)

## [2026-06-29]

### Corregido
- **La extensión simple ya no recarga interés sobre interés (#90).** `ExtendCreditOperation` creaba el crédito hijo **heredando la tasa original**, así que el saldo pendiente (que ya incluye el interés del crédito original) se volvía a multiplicar por `(1 + tasa)` → *interés sobre interés* (anatocismo). Ejemplo: 500.000 al 20% (saldo 600.000) extendido generaba un hijo de 600.000 × 1,20 = **720.000**. Ahora la extensión simple es "solo más plazo, sin dinero nuevo": el hijo se crea con **tasa 0** y redistribuye el saldo *tal cual* (600.000 en N cuotas). Para cobrar interés por la prórroga sigue existiendo la operación aparte `ExtendWithInterestOperation`. Test nuevo `ExtendCreditOperationTest`. **Verificado en datos de prod:** solo 1 crédito histórico (cred. 119, Katherine) había capitalizado por este bug — se deja **como está** por decisión del dueño. **Cierra #90.** (PR #108)

## [2026-06-23]

### Corregido
- **Caza de bugs profunda — ciclo SaaS, idempotencia, ruta de cobrador y `Money` (#89/#92/#93/#94).** Trabajado desde otra máquina y **revisado/verificado de forma independiente** aquí antes de mergear (no se aprobó a ciegas).
  - **#89 — Ciclo de vida de suscripciones.** `Subscription::scopeCurrent()` ahora **agrupa** el OR (`ends_at >= now` **O** `grace_period_ends_at >= now`) — antes el OR sin paréntesis dejaba pasar suscripciones que no debían; `startGracePeriod()` calcula la gracia desde `ends_at` (no desde `now()`); `User::hasActiveSubscription()` usa `active()->current()`; el comando `subscriptions:update-status` quedó como una máquina de estados explícita (trial → active → grace → expired). Verificado en prod: 0 lockouts indebidos por el gate.
  - **#92 — Race de idempotencia en pagos offline.** El reintento de un pago ya sincronizado podía chocar con la clave única; `PaymentController`/`PaymentSyncService` ahora capturan `QueryException` 1062 y devuelven *duplicate*/200 (sync idempotente, sin 500 ni doble cobro).
  - **#93 — Limpieza de una ruta de cobrador** sin uso.
  - **#94 — `Money` sin notación científica.** `multiply`/`divide`/`percentage` pasan por `normalize()` (`sprintf('%.8F')`), evitando que valores muy pequeños se serialicen como `1.0E-8`.
  - `FixCompletedCredits` despacha `CreditStatusSynced`. Tests añadidos. **Cierra #89/#92/#93/#94.** (PR #100)

## [2026-06-20]

### Corregido
- **La reversión de pagos dejaba pivotes vivos que doble-contaban el saldo (#84).** `PaymentReverser` clonaba los pivotes `payment_installment` al crear el pago compensatorio; como esos pivotes **no** quedaban marcados `voided`, `PaymentMaterializer` los volvía a sumar → `amount_paid` inflado y saldo incorrecto. Ahora la reversión **no crea pivotes** (el pago compensatorio queda sin distribución), se re-materializa el crédito y se valida con `PaymentInvariantGuard`. Migración idempotente `cleanup_corrupt_reversal_pivots` que **desata los pivotes de reversión y re-materializa los créditos afectados** en prod. Nuevo invariante **D**: un pago `reversal` no puede tener pivotes. (PR #84)
- **Condonar saldos es ahora robusto frente a re-materializaciones (#84).** `CreditSettlementService` registra el monto condonado en `installments.forgiven_amount`; `PaymentMaterializer` calcula `amount_paid = SUM(pivotes válidos) + forgiven_amount` y el invariante **B** pasa a ser `amount_paid == SUM(pivotes) + forgiven_amount`. Antes, re-materializar un crédito condonado **borraba** la condonación. Auditados los créditos **56 y 115** (condonación del restante por pronto pago): el saldo quedó correctamente condonado — `Expense` tipo `INTEREST_WAIVER` con `affects_cash=false` + `CreditRestructureLog` tipo CONDONATION. (PR #84)
- **#3 — Fuga de scope de supervisor cerrada en todas las superficies.** `CollectorVisibilityResolver::visibleCollectorIds()` (fail-closed: `default => []`) + `RoleAwareQueries::visibleCollectorScope()` se aplican ahora en **todas** las vistas de supervisor (resumen de equipo, tendencia de recaudo, alertas de rendimiento, "cobrados hoy"). Un supervisor asignado ve **solo** su equipo en cada superficie; `admin`/`sees_all_collectors` ven toda la empresa; supervisor sin asignaciones ve cero. (PR #86)
- **5 hallazgos MEDIO de la re-auditoría (#87).** `bad_debt_writeoff` removido de `ALLOWED_CATEGORIES` (causaba **500** en la PWA); `PaymentSyncService` compara con `Money` (eliminada la tolerancia `±0.01`); typo `amount_applied`→`applied_amount` en `PaymentController`; `getTeamSummary`/`getTeamCollectionTrend` aceptan scope opcional de cobradores; `getPerformanceAlerts` reescrito de **N+1** a 2 queries agregadas (con bindings, sin interpolar `$today`). (PR #87)
- **3 hallazgos BAJO (#88).** Gating del "gasto del día" por `effective()` (aprobado o no-requiere-aprobación); `approval_status`/`requires_approval` fuera de `$fillable` de `Expense` (mass-assignment); `DATEDIFF` bindeado (`?`) en métricas. (PR #88)

### Cambiado
- **La vista de pagos del crédito (Filament) excluye anulados y reversiones del recaudado (#85).** "Total Recaudado", "Pagos Válidos" y el promedio ya **no** cuentan pagos `voided` ni `reversal`; las filas anuladas/reversión se muestran atenuadas/tachadas con badge + el `void_reason` en notas — para que al auditar con el cobrador quede claro qué pagos cuentan y cuáles no. (PR #85)

### Seguridad / Datos
- **Auditoría contable forense (consecuencias de los bugs).** Tras corregir el doble-conteo de reversiones (#84) se auditó el impacto financiero real en prod: la **caja está intacta**, la **cartera quedó saneada** (créditos afectados re-materializados por la migración) y los 326 créditos reconcilian limpio. El único sobrecargo histórico real es el cred. 119 (~100k por el bug de extensión simple, #90), dejado como está por decisión del dueño.

## [2026-06-19]

### Corregido
- **Ruido benigno de Livewire en notificaciones Filament silenciado (#59 → #83).** `bootstrap/app.php` ignora `CorruptComponentPayloadException` y el `TypeError` de hidratación de `Filament\Notifications\Collection` (no afectan al usuario; solo ensuciaban los logs). **Resuelve #59.** (PR #83)

### Seguridad
- **`guzzlehttp/guzzle` 7.12.1 + `guzzlehttp/psr7` 2.12.1 (#82).** Bump dentro de constraints tras `composer audit`; desplegado a prod de forma segura. (PR #82)

### Tests / Infra
- **Aislamiento de vistas compiladas en la suite (#81).** `phpunit.xml` define `VIEW_COMPILED_PATH=storage/framework/testing/views` para que los tests **no** compilen vistas en el directorio que sirve el servidor web (causaba `touch(): Utime failed: Operation not permitted` en `/admin/login` por mezcla de dueños www-data/dev). Fix durable. (PR #81)

## [2026-06-18]

### Corregido
- **5 ALTOS de la auditoría Filament/PWA (#67–#72).** Trabajado desde otra máquina (fast-forward limpio sobre el `main` de L13/Filament5/Vite8), mergeado a `main`:
  - **#67** — `PaymentPolicyValidator` **centralizado** (saldo/cuota/parcial/sobrepago) compartido por PWA + Filament; ambos cargan con `Credit::activeLeafCredits()` (ya **no** se puede pagar un crédito padre/terminal); `SyncPaymentsRequest` rechaza fechas futuras.
  - **#68** — `CreditForm` lee los límites de `CompanyFinancialSettings` (+ nullsafe redundante quitado).
  - **#69** — `CreateExpenses` (Filament) usa `applyApprovalPolicyForCreator` (egreso de admin auto-aprobado, idéntico a la PWA).
  - **#70** — "Cobrado hoy" se atribuye al cobrador **asignado**, no al que registró el pago.
  - **#72** — Aging del cobrador: bucket `1_30` correcto (la etiqueta `8_30` estaba mal).
  
  (El sub-ítem de idempotencia de Filament de #67 quedó **fuera de alcance** — requiere clave estable + verificar datos de prod antes de crear el índice único.) (PR #78)

## [2026-06-16]

### Corregido
- **Visibilidad supervisor→cobrador en Morosidad/rendimiento** (#71). `CollectorVisibilityResolver` ahora se aplica también en `DelinquencyController` (PWA), `DashboardController::supervisorDashboard` (PWA) y el widget Filament `CollectorPerformanceTodayTable` — antes un supervisor *asignado* veía su equipo en unas vistas y **toda la empresa** en otras (fuga de visibilidad). `DelinquencyMetricsService` acepta un scope opcional `?array $collectorIds` en sus métodos (filtro por `credits.collector_user_id` + sufijo de caché propio para no contaminar la vista company-wide); el PAR-trend scoped se calcula **en vivo** (no existen snapshots por-cobrador). `admin`/`sees_all_collectors` siguen viendo toda la empresa; supervisor sin asignaciones ve cero. Pint + PHPStan (0 errores) + **370 tests** verdes.
- **Auditoría — 5 hallazgos MEDIO (#73–#77):** **#73** la integridad de `Income` ya se valida en el hook `creating` del modelo (cubre la creación desde Filament, no solo `FinanceAutoLogger`) — test que lo documenta. **#74** el preview de cuota de la PWA se marca como *aproximación de tasa plana* (la cuota real la calcula el servidor según el `interest_method` de la empresa). **#75** `PaymentPolicyValidator` usa comparaciones `Money` (BC Math) y elimina la tolerancia flotante mágica `±0.01` (regla "no floats"). **#76** los controllers PWA usan `Carbon::today()->toDateString()` (consistencia con los servicios cerca de medianoche/DST). **#77** "Collectors activos" unificado a *cobradores con cartera activa* en PWA y Filament (la actividad del día — cuántos cobraron — queda como contexto en el widget). Tests + Pint + PHPStan verdes.

### Cambiado
- **Migración a Vite 8 + laravel-vite-plugin 3** (#35). `vite` 5.4 → **8.0.16**, `laravel-vite-plugin` 1 → **3.1**. `@vitejs/plugin-vue` (6) y `vite-plugin-pwa` (1.3) **ya soportaban Vite 8** (sin bump). El Node de prod (20.19.5) cumple el requisito de Vite 8 (`^20.19`). `vite.config.js` **no necesitó cambios** y cero cambios de código; el build genera app + PWA + **Service Worker** correctamente (`manifest.json` en `public/build/`, SW en `public/pwa-sw.js`, dark-mode intacto). Warning conocido de `vite-plugin-pwa` (`inlineDynamicImports` deprecado en Vite 8) — cosmético, no bloquea. **Cierra #35.** ⚠️ Requiere **QA en device** del PWA (offline + registro/sync de pagos + actualización del SW) antes de desplegar.
- **Majors de backend: Laravel 13 + PHPUnit 12 + spatie-permission 8 + Tinker 3**. Laravel 12.62 → **13.15**, PHPUnit 11 → **12.5**, `spatie/laravel-permission` 6 → **8**, `laravel/tinker` 2 → 3 (+ deps `sebastian/*`). Único cambio de código de la app: 2 `Collection::sum(fn (Installment) => …)` → `->sum('remaining_amount')` en `DelinquencyMetricsService` (Laravel 13 endureció los generics de `Collection::sum`) + limpieza de la entrada huérfana en el baseline de PHPStan. **Sin migraciones nuevas** (spatie 8 es compatible con las tablas existentes). Verificado: Pint + PHPStan (0 errores) + **354 tests** verdes; la app arranca en Laravel 13. Filament 5 ya soportaba Laravel 13.
- **Migración a Filament v5 + Livewire v4**. Filament 4.11.7 → **5.6.7** y Livewire 3.8 → **4.3.1** vía la herramienta oficial `filament/upgrade` (codemod Rector sobre `app/`). **No requirió cambios de código de la app** — las APIs de Filament que usa este proyecto no rompieron en v4→v5; el cambio es solo bump de constraints (`filament/filament ^5`) + assets v5 republicados. El plugin `leandrocfe/filament-apex-charts` (5.1.0) ya soporta Filament 4 y 5. Verificado: build + Pint + PHPStan (0 errores) + **354 tests** verdes. Probablemente resuelve [#59](https://github.com/jdredondo/credify/issues/59) (notificaciones). ⚠️ **Requiere QA del panel admin (claro + oscuro) antes de desplegar a prod.**
- **Migración a Tailwind CSS v4** (#35). El build de frontend pasó de Tailwind 3.4 → 4.3 con el codemod oficial `@tailwindcss/upgrade`: `@tailwind` → `@import "tailwindcss"`; config JS (`tailwind.config.js`, eliminado) → CSS (`@source` + **`@custom-variant dark`** para preservar el dark-mode por **clase** `.dark`); PostCSS ahora usa `@tailwindcss/postcss` (`autoprefixer` eliminado, v4 lo hace nativo); y se renombraron utilities en 36 templates (`shadow-sm`→`shadow-xs`, `outline-none`→`outline-hidden`, `bg-gradient-to-*`→`bg-linear-to-*`, `flex-shrink-0`→`shrink-0`). Se añadió el shim de compatibilidad del color de borde por defecto (v4 usa `currentColor`). **Build verde** y dark-mode compilado como class-based (390 reglas `:is(.dark *)`, 0 `prefers-color-scheme`). El CSS publicado de Filament (`public/css/filament/**`) se gestiona aparte y no se tocó. ⚠️ **Requiere QA visual (claro + oscuro, PWA + panel admin) antes de desplegar a prod.**

### Mantenimiento
- **Batch de dependencias (Tier 1, dentro de constraints — sin majors)**: Filament 4.10 → 4.11.7; patches/minors de Laravel (`sanctum`, `pail`, `pint`, `boost`, `sail`, `mcp`), `larastan` 3.10, **PHPStan 2.1 → 2.2**, `maatwebsite/excel`, `collision`, `guzzle`; npm: `vue` 3.5.38, `dexie` 4.4.3, `vite-plugin-pwa` 1.3, `workbox-*` 7.4.1. Se corrigieron 3 hallazgos nuevos del PHPStan 2.2 más estricto (`amount_paid` como `float` en las estrategias de interés; comparación redundante en `AuditPresenter`). Build + 354 tests + Pint + PHPStan verdes.

## [2026-06-15]

### Corregido
- **Tabla `failed_jobs` faltante (cola `database`).** La migración `create_jobs_table` solo creaba `jobs`; sin `failed_jobs`, `queue:work --tries=N` no podía registrar un job que agota sus reintentos. Añadida migración idempotente (`Schema::hasTable` guard). Detectado en la auditoría de prod. (#57)
- **El Service Worker no recogía deploys nuevos de forma fiable en iOS.** iOS no re-verifica el SW al reabrir la PWA desde background, así que seguía sirviendo el precache viejo tras un deploy (causó la confusión en los fixes de iOS: el CSS desplegado era correcto pero el dispositivo mostraba el viejo). `main.js` ahora captura la `registration` y en `visibilitychange` → `visible` llama `registration.update()` una vez: si hay versión nueva, `onNeedRefresh` la activa; si no, no hace nada. Sin `setInterval` ni loops. (#36)
- **La caja descontaba egresos pendientes de aprobación.** `getCashBase` (dashboard admin), `getCashPosition` y `CashFlowService` sumaban *todos* los egresos `affects_cash=true` sin filtrar `approval_status` — un gasto de un cobrador reducía la caja **antes** de que el admin lo aprobara. Ahora la caja solo descuenta egresos **efectivos** (aprobados o que no requieren aprobación); pendientes y rechazados no descuentan hasta aprobarse. (#50)

### Cambiado
- **Aprobación de egresos estandarizada por rol — fuente única de verdad.** Nuevo `Expense::applyApprovalPolicyForCreator()` (admin/super_admin → auto-aprobado; supervisor/collector → pendiente) reemplaza la lógica duplicada de `ExpenseController` + `SyncController`, se aplica también a retiros de socios y se **cierra el gap del panel Filament** (`CreateExpenses`: un no-admin que cree un gasto ahí también queda pendiente). Nuevo scope `Expense::scopeEffective()` como regla canónica de saldo, usado por `getCashBase`, `getCashPosition`, `getNetProfit`, `getExpenseBreakdown`, `CashFlowService` y `FinancialController` para que **todos** los cálculos de caja sean consistentes. Los egresos de sistema (desembolsos, ajustes de crédito) y el flujo de capital de socios conservan su propia autorización. (#50)

### Seguridad
- **Advisories de `composer` parcheados.** `composer update` dentro de los constraints actualizó los 12 paquetes vulnerables (`symfony/*`, `guzzlehttp/psr7`, `phpoffice/phpspreadsheet`, `symfony/html-sanitizer`, `laravel/framework`, …): de ~25 advisories a **1**. El restante (`filament/actions` CVE-2026-48067, *medium*) no tiene parche en la línea Filament 4 — requeriría el major a Filament 5; mitigado por el global scope multi-tenant (`MultiTenantScope`). 354 tests verde. (#35)

### Mantenimiento
- **Tests migrados a atributos PHPUnit.** Reemplazada la metadata en doc-comments (`/** @test */`, `@dataProvider`) por atributos PHP 8 (`#[Test]`, `#[DataProvider]`) en los 6 archivos `tests/Unit` que aún la usaban — elimina los warnings *"Metadata in doc-comments is deprecated"* y desbloquea PHPUnit 12. **354 tests siguen pasando**. (#37)
- **Dependabot — bumps de bajo riesgo.** `axios` → 1.18, `postcss` → 8.5.15, y GitHub Actions (`checkout` v6, `cache` v5, `setup-node` v6, `configure-pages` v6, `upload-pages-artifact` v5). Cierra los PRs de Dependabot #1, #2, #3, #10, #11, #18 y #20. (#35)
- **`@vitejs/plugin-vue` 5 → 6** (#21). Major sin riesgo: plugin-vue 6 soporta Vite 5 (peer `^5 || ^6 || ^7 || ^8`), no fuerza upgrade de Vite. Build OK. (#35)

## [2026-06-14]

### Cambiado
- **Cabeceras unificadas a un único estilo (`<PwaHeader>`):** se estandarizaron TODAS las vistas a la app-bar esmerilada `<PwaHeader>`. Las 7 vistas que aún usaban hero/gradiente (`<PwaHero>`) — ClientDetail, CreditDetail, CreditCreate, PaymentView, Profile, Partners y PaymentsHistory — migraron al mismo header (botón "Volver", título + subtítulo consistentes). Solo **HomeView** (landing del dashboard) conserva un estilo propio a propósito. En CreditDetail el bloque de saldo (Total/Pendiente/estado) pasó del hero a una `.pwa-card` de resumen, con colores válidos en claro y oscuro. Esto **resuelve/supera** la Fase 2 planteada en #34 (la decisión fue *unificar* a `<PwaHeader>`, no mantener heroes). (#34)

### Eliminado
- **Componente `<PwaHero>` y su CSS** — dead code tras la unificación: borrado `PwaHero.vue`, su `import`/registro global en `main.js` y el bloque `.pwa-hero*` en `pwa.css`. Se conservan `.pwa-hero-card` y `.pwa-hero-header` (clases distintas, aún en uso). (#34)

## [2026-06-12]

### Añadido
- Componentes de layout reutilizables **`<PwaHeader>`** y **`<PwaRefreshButton>`**, registrados globalmente en `main.js` — fuente única de verdad para las cabeceras app-bar de la PWA. (#33)
- Componente **`<PwaHero>`** — cabecera hero/gradiente reutilizable: botón "Volver" unificado, eyebrow/título/subtítulo, tono `emerald`/`indigo`, slots `#actions` y por defecto. (#34)

### Cambiado
- **Estandarización de cabeceras — Fase 1:** las 14 vistas con app-bar esmerilado ahora usan `<PwaHeader>`. **Credits** y **Clients** (que usaban `text-xl` + layout en bloque) ahora coinciden con **Collections** (`text-lg`, barra estándar); sus filas de filtros/búsqueda pasaron al slot por defecto del header. Las otras 12 vistas se ven igual (solo se componentizaron). (#33)
- **Estandarización de cabeceras — Fase 2:** 7 vistas hero migradas a `<PwaHero>` (ClientDetail, CreditDetail, CreditCreate, PaymentView, Profile, Partners y PaymentsHistory) — botón "Volver", título y espaciado consistentes; gradientes consolidados (emerald estándar + indigo para Socios). Se eliminó el CSS muerto resultante (`.pwa-hero-header` global + clases `.*-hero` scoped). **HomeView** (landing del dashboard) se conserva como estilo distinto a propósito. (#34)
- **Modo oscuro iOS-26 (Fase A):** la paleta dark pasa de negro puro a **aqua-emerald** (base `#04130E`, superficies teal, tarjetas glass translúcidas + `backdrop-blur`, acento aqua). **Contraste corregido** (≥4.5:1; texto muted/faint fuera de slate-600). Dashboards Admin/Supervisor migrados a `.pwa-card`. (#43)
- **Dashboard accionable + rol de Perfil (Fase B):** las tarjetas del dashboard admin ahora navegan a su sección (Finanzas, Pagos, Morosidad, Créditos) con feedback táctil `card-press`; `ProfileView` muestra el **rol real** del usuario (antes "Cobrador" hardcodeado). (#43)
- **Fix de botones en dark:** `bg-*/6` y `bg-*/8` no se generan en Tailwind (solo emite opacidades en pasos de 5), por lo que los botones "Sincronizar Ahora", "No paga" y "Cobrar" mostraban su fondo **claro** en modo oscuro. Corregidos a opacidades válidas (`/10`, `/15`). (#43)
- **Barrido de opacidades inválidas:** corregidas TODAS las opacidades de color con pasos inválidos (`/4 /6 /7 /8` → `/5` o `/10`) en **21 vistas** — elimina los tints y bordes dark que silenciosamente no renderizaban (mismo bug raíz que los botones). Las fracciones (`w-1/4`, etc.) no se tocaron. (#43)
- **Vista Socios alineada al dark:** quitado el índigo que no encajaba (hero, links "Registrar movimiento", saldo positivo → emerald) y la card "Capital Total" (que se renderizaba **clara**) ahora usa `pwa-hero-card` (gradiente oscuro). (#43)

### Corregido
- **La dirección del cliente no aparecía en los cards de Cobros:** `CollectionController` nunca devolvía `address` (el feature `ef9c3cf` cableó el card/store/accessor pero no el endpoint). Las direcciones **sí se guardan** — el bug era de lectura. Se añadió el accessor único **`Client::formatted_address`** (dirección principal, o la primera si ninguna está marcada), se devuelve en cobros, y se **estandarizaron las lecturas** (`SyncController`, `ClientController@show` usaban formatos distintos) + `is_primary` por defecto `true` en el formulario Filament. (#39)

## [2026-06-11]

### Añadido
- README enriquecido: qué es el proyecto, stack, roles, puesta en marcha, scheduler, CI y enlaces a docs. (#31)
- Sección **Project State** y nota de tooling de IA en [`CLAUDE.md`](CLAUDE.md) (doc canónica para agentes de IA). (#28)

### Cambiado
- `composer.json`: nombre/descripción/keywords reales del proyecto (antes el skeleton `laravel/laravel`); `composer.lock` re-sincronizado sin cambios de dependencias. (#31)
- `.env.example`: apunta a la conexión real **MySQL/MariaDB `credify`** por defecto (antes traía `sqlite`, que no coincidía con el proyecto ni el CI). (#31)
- Repositorio: **desactivado `delete_branch_on_merge`** (la rama `dev` ya no se borra al mergear); agregados *topics* de GitHub. (#31)

### Corregido — PWA iOS (modo standalone)
- **Franja blanca inferior / Bottom Nav desplazado:** se eliminó el antipatrón `position: fixed` + `height: 100%` en `html`/`body`; ahora flujo natural del documento + `overscroll-behavior-y: none` + `min-height: 100dvh`. (#29)
- **Títulos detrás del reloj / Dynamic Island:** se aplicó el inset de safe-area superior a todas las cabeceras sticky vía la clase `safe-area-top` (y la regla global `div.pwa-sticky-header`). (#30, #32)

### Eliminado
- **Código muerto:** capa legacy Livewire de la PWA (`app/Livewire/App/**` + vistas), componentes Vue/JS sin uso, comandos de consola de reparación/auditoría puntuales, listener no registrado y scripts/artefactos de auditoría. Todo verificado con cero referencias. (#28)
- **Tooling de IA del repo:** `.claude/`, `.ai/`, `.mcp.json`, `AGENTS.md`, `boost.json` (regenerable con `php artisan boost:install`). `CLAUDE.md` es ahora la doc canónica para IA. (#28)

---

> **Convención de cabeceras (PWA):** usa `<PwaHeader title="…">` (app-bar esmerilado) para listas/formularios y, próximamente, `<PwaHero>` para vistas de detalle/landing — nunca marques el header a mano. Así se mantiene la estandarización.
