# Dashboard de salud del negocio (dueño) — PWA + Filament

- **Fecha:** 2026-09-08
- **Rama:** `feat/dashboard-salud-negocio`
- **Estado:** diseño aprobado en brainstorming (mockup v2 revisado con el usuario); pendiente de plan.
- **Mockup:** HTML local fuera del repo, `C:\Users\jredondo\credify\informes\2026-09-08-mockup-salud-negocio.html` (datos reales de producción al 2026-09-08, por eso no se versiona). El artifact donde se publicó al principio ya no existe.
- **Motivación:** el home del dueño no responde las preguntas del dueño. La tarjeta "Utilidad mes" marca **$0** —porque busca una categoría de ingreso que el sistema nunca escribe—, no existe ninguna métrica de rentabilidad real, y varias tarjetas salen en cero por huecos del payload. El pedido textual fue: *"un dashboard que me muestre el estado del negocio, no números en 0, que me diga cómo fue la última semana, el último mes, quiero saber qué tan rentable está siendo mi negocio"*.

## Cómo funciona hoy (auditado)

**Ruta de datos:** `AdminHome.vue` ← `stores/dashboard.js` ← `GET /pwa/dashboard` → `DashboardController::adminDashboard` (`:232`) → `AdminDashboardMetricsService::getMetrics(int $companyId)`.

**Lo que ve el dueño:** carrusel de 4 KPIs (Patrimonio `business_total` = por_cobrar + cash_base · **"Utilidad mes"** `interest_month` · Caja `cash_base` · Mora `delinquency_rate`), sección "Hoy" (`collected_today`, `disbursements_today`, `operational_expenses_today`), card "Saldo por Cobrar" (vigente/vencida con criterio PAR + sparkline `collections_trend` de 7 días) y `PendingApprovalsAlert`.

**Ya se calcula y nadie lo muestra:** `disbursed`, `cartera`, `interest_earned` (histórico), `collected_trend` y todo `operational_summary` — `due_today_count`, `paid_today_count`, `overdue_count`, `active_collectors`, `collection_rate_today`.

**Tendencias:** `DashboardSnapshot` (5 campos) escrito por `credify:snapshot-dashboard` (23:55) y leído por `DashboardTrendService`, que devuelve `null` mientras no exista un snapshot con fecha ≤ hoy−7.

### El problema de fondo, medido en producción (2026-09-08)

- `IncomeCategory::LOAN_PAYMENT_INTEREST` y `LOAN_PAYMENT_PRINCIPAL` **no las escribe ningún servicio**: solo aparecen en el enum, comentarios y una migración. `PaymentRegistrar.php:197` y `PaymentManager.php:226` escriben la categoría genérica `'payment'`.
- En J&A Asociados (`company_id=1`): **2.272** filas de ingreso con categoría `payment` ($273.729.500) y **cero** filas `loan_payment_interest`.
- Por eso `interest_month` —que filtra estrictamente por `loan_payment_interest`— devuelve **$0**. Verificado en vivo contra prod.
- `IncomeCategory::PAYMENT` está marcada `affectsCapital() = true` (`:343`) **y** `affectsProfit() = true` (`:395`). Consecuencia: `CashFlowMetricsService::getNetProfit()` —que existe y nadie llama— contaría los $273,9M completos como utilidad, **5,5× inflado**.
- Medición real: de **$273.929.500** cobrados históricamente, **$50.060.364 (18%) es interés** y **$223.669.136 (82%) es capital recuperado**.

**Servicios de rentabilidad existentes pero sin usar** (`app/Services/Metrics/CashFlowMetricsService.php`): `getNetProfit`, `getNetProfitThisMonth`, `getFinancialSummary`, `getPortfolioROI`, `getRecoveryRate`, `getIncomeVsExpenseTrend`, `getIncomeBreakdown`, `getExpenseBreakdown`. Todos leen `incomes` por categoría → hoy darían 0 o inflado. **No se pueden cablear tal cual.**

### Bugs de cero identificados

1. **Meta del día del dueño siempre en 0.** `due_today` solo se setea en la rama del cobrador (`DashboardController.php:84`); el payload de admin nunca lo trae, así que `stats.due_today?.amount` y `todayProgress` (`AdminHome.vue:9,94-98`) rinden 0 por construcción.
2. **`interest_month` = 0** estructural (arriba).
3. **Polaridad engañosa:** `por_cobrar` creciente se pinta verde (`DashboardTrendService.php:19-24`) aunque al mismo tiempo caiga la caja y suba la mora. En prod hoy: por cobrar ▲3,3% en verde, caja ▼57,5%, mora ▲9%.

### Infraestructura sana (no hay que arreglarla)

Cron del scheduler activo; `dashboard_snapshots` con 43 días consecutivos (2026-07-27 → 2026-09-07) y `par_snapshots` con 142 filas. Las flechas "vs semana" pueden funcionar hoy mismo.

## Decisiones (del brainstorming)

1. **Ambas superficies, coordinadas** (PWA del dueño + panel Filament) desde **un servicio único**, para que nunca muestren cifras distintas.
2. **Rentabilidad = las cuatro vistas**: utilidad neta (+ vs periodo anterior), rendimiento del capital, interés bruto y flujo de caja.
3. **Fuente de datos: camino C (híbrido).** Derivar del ledger de cuotas **ahora**; sanear el ledger contable (partir el pago en capital/interés + backfill) **después**, como proyecto propio.
4. **Ventanas: ambas.** Rolling (7/30 días contra los previos) como titular + mes calendario para el cierre.
5. **Orden: patrimonio y caja primero**, rentabilidad segundo (decisión del usuario sobre el mockup v1).
6. **Anti-ceros:** ninguna tarjeta muestra un cero pelado; si el dato no es confiable, dice por qué.

## Diseño

### A. `BusinessMetricsService` — la fuente única

Servicio nuevo, orientado a periodo: `forPeriod(int $companyId, CarbonInterface $from, CarbonInterface $to)`.

**Regla de imputación *interés-primero* (el corazón).** Para cada cuota se recorren sus aplicaciones (`payment_installment.applied_amount`) en orden de `payments.payment_date` (desempate por `payment_installment.id`). El acumulado cubre primero el `interest_amount` de esa cuota y el resto es capital; la porción de interés de cada aplicación es:

```
GREATEST(0, LEAST(acum_después, interest_amount) − LEAST(acum_antes, interest_amount))
```

Así **cada peso de interés queda fechado con el pago que lo generó**, que es lo que permite responder "cuánto gané entre X e Y". Solo pagos con `voided = 0`. Es la misma convención que ya usa `interest_earned`, así que no se crea una segunda verdad.

**Validación obligatoria de arranque:** el interés histórico derivado para `company_id=1` debe dar **$50.060.364**, idéntico al calculado por la vía independiente. Ya se comprobó en SQL contra producción antes de escribir el servicio; queda como test.

**Gastos del periodo (CORREGIDO durante la implementación, 2026-09-09):** la primera versión de este spec decía "`expenses` con `affects_profit = 1`". **Está mal y habría destrozado la utilidad.** Medido en producción, esa columna está mal poblada: **$63,8M + $11,6M + $8,7M de desembolsos** y **$10,6M de retiros de utilidad** tienen `affects_profit = 1`. Un desembolso es capital colocado, no un costo; un retiro es reparto de la utilidad, no un costo de generarla. Con el filtro original la utilidad histórica salía **−$45M** en vez de **$49,5M**.

La regla correcta: **decidir por el enum `ExpenseCategory`, nunca por la columna** — categorías con `affectsProfit() === true`, **excluyendo además `PROFIT_WITHDRAWAL` y `DIVIDEND_PAYMENT`** (el enum los marca `true` con el criterio "reduce utilidad distribuible", que es reparto y no costo). Sobre eso, el scope `effective` (`approval_status = 'approved' OR requires_approval = 0`), por `operation_date`. Verificado en datos reales: gastos operativos históricos de la empresa 1 = **$566.000** (other_operational $266k + interest_waiver $200k + office_supplies $100k), no $94,7M.

**Ingresos de utilidad del periodo** = interés derivado del ledger de cuotas **+** `incomes` cuya categoría tenga `affectsProfit() === true`, **excluyendo explícitamente `payment` y `loan_payment_interest`**. Esa exclusión es lo que evita el doble conteo: `payment` ya está representado por la derivación, y `loan_payment_interest` lo estaría si algún día empieza a escribirse. Cuando se ejecute el saneamiento del ledger (proyecto aparte), la fuente cambia a `loan_payment_interest` y se retira la derivación.

**Métricas expuestas:**

| Método | Definición |
|---|---|
| `interestCollected` | Interés cobrado en el periodo (derivado, bruto) |
| `operatingExpenses` | Gastos del periodo que afectan utilidad |
| `netProfit` | Ingresos de utilidad − gastos operativos |
| `margin` | `netProfit / ingresos de utilidad del periodo` (no solo el interés: incluye comisiones y recargos cuando existan) |
| `returnOnEquity` | `netProfit / patrimonio actual`, mensualizado |
| `cashFlow` | `SUM(incomes.amount)` del periodo − `SUM(expenses.amount)` del periodo con `affects_cash = 1` y scope `effective`, ambos por `operation_date`. Es movimiento de efectivo, **no** utilidad: incluye capital y desembolsos |

Los stocks (patrimonio, caja, por cobrar, vencido, mora) se siguen tomando de `AdminDashboardMetricsService`, que ya los calcula bien; el servicio nuevo **no los duplica**.

### B. Los cuatro bloques

**Bloque 1 · Patrimonio y caja** — estado actual, Δ vs 7 días (snapshots existentes).
Patrimonio · Caja disponible · Capital de socios · Colocado por cobrar (con cartera total emitida como contexto).

**Bloque 2 · Rentabilidad** — rolling 30 días vs los 30 previos.
Utilidad neta (titular) · Interés cobrado · Margen · Rendimiento mensual sobre patrimonio.

**Bloque 3 · Cartera y riesgo.**
Composición de por cobrar (vigente vs en riesgo, criterio PAR) · % en riesgo con Δ semanal · créditos activos y cuotas vencidas.

**Bloque 4 · Ritmo** — hoy · mes en curso (con días transcurridos) · cierre del mes pasado.
Cobrado hoy · **tasa de cobro del día** · mes en curso · cierre mes anterior · desembolsado 30d · sparkline de 30 días (hoy son 7).

### C. Contrato anti-ceros

Cada métrica viaja en el payload como un objeto con estado, no como un número pelado:

```
{ value, state, note?, trend: { pct, direction, good } | null }
```

- **`ok`** — dato confiable.
- **`no_data`** — no hay base para calcularlo (p. ej. tendencia sin snapshot de hace ≥7 días). La UI dice *"sin datos suficientes"*, **nunca 0%**.
- **`unreliable`** — el dato existe pero engaña; se muestra con su advertencia. Caso canónico: **margen con `operatingExpenses == 0`** en el periodo → nota *"no hay costos registrados en el periodo"*.

Un valor legítimamente en cero (un día sin cobros) es `ok` con copy de estado vacío ("sin cobros hoy"), no una tarjeta muda.

### D. Polaridad corregida

`por_cobrar` creciente deja de pintarse verde automáticamente. Si sube mientras `cash_base` baja o `delinquency_rate` sube, el estado es **`watch` (ámbar)**, no `good`. La polaridad pasa a evaluarse en conjunto, no métrica por métrica aislada.

### E. Bugs que se corrigen

1. Incluir `due_today` en el payload del rol admin → arregla la meta del día y su barra de progreso.
2. Reemplazar "Utilidad mes" (interés bruto mal etiquetado) por **utilidad neta real**; el interés bruto queda como su propia tarjeta, correctamente nombrada.
3. Exponer lo que ya se calcula y está oculto: `collection_rate_today`, `overdue_count`, `active_collectors`.

### F. Las dos superficies

- **PWA (móvil, dueño):** carrusel de KPIs con el Bloque 1 al frente y secciones para el resto. Mobile-first, de un vistazo.
- **Filament `/admin`:** los mismos números en una fila de widgets, leyendo del mismo servicio. Los gráficos ricos (serie mensual ingresos vs gastos, desgloses, tabla por cobrador) son Fase 2.

### G. Sin migraciones en Fase 1

Las métricas de rentabilidad son de **flujo** y se calculan en vivo del ledger para cualquier par de fechas, así que no necesitan snapshots nuevos. Los stocks ya se snapshotean. **Fase 1 no lleva ninguna migración de base de datos.**

## No-objetivos

- **No** sanear el ledger de ingresos (partir el pago en capital/interés + backfill de 2.272 filas). Es un proyecto aparte, con sus propios tests, porque toca el camino crítico de registro de pagos.
- **No** modificar `PaymentRegistrar` / `PaymentManager` ni nada del registro de pagos.
- **No** cambiar el criterio PAR con que se mide la mora.
- **No** Fase 2 (gráficos ricos de Filament, bloques 3 y 4 enriquecidos).
- **No** resolver que el negocio casi no registra gastos operativos: es un tema de proceso, no de software. El contrato anti-ceros lo señala, no lo inventa.

## Riesgos / gotchas

- **Doble conteo de interés.** Si `loan_payment_interest` empieza a escribirse, la derivación y la categoría sumarían dos veces. Mitigado por la exclusión explícita del §A + un test que lo bloquea.
- **Cobertura del pivote.** El interés derivado cuadra exacto ($50.060.364), pero el capital derivado difiere ~$200.000 (0,09%) del calculado por cuotas: hay pagos fuera de `payment_installment`. Añadir una verificación que reporte la brecha en vez de esconderla.
- **Por qué el margen sale ~98% y NO es un bug (confirmado por el dueño, 2026-09-09).** Los gastos operativos del negocio **se registran en una empresa hermana**, no en esta. Por eso `operating_expenses` es casi cero y la utilidad neta ≈ interés bruto. Es una característica estructural de cómo llevan la contabilidad, no un descuido de captura: **no “arreglar” con un umbral ni asumir datos faltantes**. El estado `unreliable` que aparece cuando no hay costos en el periodo se deja tal cual — sigue siendo cierto que esa utilidad es prácticamente el interés bruto, que es justo lo que el dueño necesita tener presente al leerla.
- **Utilidad optimista por falta de costos.** En 4 meses solo hay 2 gastos que afectan utilidad ($266.000 y $100.000), así que utilidad neta ≈ interés cobrado y el margen sale ~98%. Es real, no un bug; el estado `unreliable` existe justo para esto.
- **Rendimiento.** La imputación recorre `payment_installment` con window functions (3.034 filas hoy — trivial). Documentar que si crece hay que indexar o cachear por periodo.
- **`MultiTenantScope` es NO-OP en consola** → filtrar `company_id` explícitamente en cualquier comando. Ver [[credify-filament-auth]].
- **PHPStan level 5 + Pint** antes de push. Ver [[credify-ci-phpstan-preflight]].
- **Deploy:** cambia backend + assets de la PWA → build con `VITE_SENTRY_DSN` **y** `VITE_POSTHOG_KEY` en el entorno, o esas integraciones quedan apagadas en el bundle. Ver [[credify-dev-tooling]].

## Verificación

- **Datos reales en dev:** la BD de dev es ahora una copia de producción (2026-09-08), así que los tests corren contra los 547 créditos y 2.299 pagos reales.
- Interés histórico derivado de `company_id=1` = **$50.060.364** (test de anclaje).
- Test de **no doble conteo**: si se insertan filas `loan_payment_interest`, el total no se duplica.
- Test del **contrato anti-ceros**: margen sin gastos en el periodo → `state = unreliable` con nota.
- Test de que **`due_today` llega en el payload del admin** y la barra de progreso deja de ser 0.
- Test de **polaridad conjunta**: por cobrar ▲ con caja ▼ → `watch`, no `good`.
- Comparación panel viejo vs nuevo en dev con los mismos datos.
- Suite completa + PHPStan L5 + Pint verdes; smoke E2E de Playwright sigue pasando.
- **CHANGELOG.**
