# Dashboard de Salud del Negocio — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Reemplazar el panel del dueño (PWA + Filament) por uno que muestre patrimonio y caja, rentabilidad real, cartera/riesgo y ritmo — con comparativas rolling y de mes, y sin ceros mudos.

**Architecture:** Un servicio nuevo (`BusinessMetricsService`) calcula la rentabilidad por periodo derivando el interés del ledger de cuotas con imputación *interés-primero* (`InterestAttributionService`), sin tocar el ledger contable ni el registro de pagos. Los stocks (patrimonio, caja, por cobrar, mora) siguen viniendo de `AdminDashboardMetricsService`. Toda métrica viaja envuelta en un value object `Metric` que codifica su estado (`ok` / `no_data` / `unreliable`), y las dos superficies consumen exactamente los mismos números.

**Tech Stack:** Laravel 13 / PHP 8.3, MariaDB 10.6 (CTE + window functions), Vue 3 (PWA), Filament 5, PHPUnit, Playwright.

**Spec:** `docs/superpowers/specs/2026-09-08-dashboard-salud-negocio-design.md`

---

## ⚠️ Gotcha crítico antes de empezar

La BD de **dev es ahora una copia de producción**, pero **CI corre con BD limpia** (`mariadb:10.6` vacía). Por lo tanto:

- **Todo test crea su propia empresa con factories y asevera solo sobre ella.** Nunca aseveres sobre totales globales, ni `assertDatabaseCount` sobre tablas completas, ni sobre datos de J&A Asociados (`company_id=1`) — eso pasa en dev y falla en CI.
- El anclaje contra datos reales (**$50.060.364** de interés histórico) va en un **comando artisan** (Task 3) que se corre a mano, no en la suite.
- Ya hay precedente de este error: `SnapshotDashboardMetricsTest` usa `assertDatabaseCount` sobre la tabla completa y falla en dev por datos ajenos.

Además: `MultiTenantScope` es **NO-OP en consola**, así que todo query filtra `company_id` explícitamente.

---

## File Structure

| Archivo | Responsabilidad |
|---|---|
| `app/Support/Metric.php` *(crear)* | Value object del contrato anti-ceros: `value` + `state` + `note` + `trend` |
| `app/Services/Metrics/InterestAttributionService.php` *(crear)* | Única responsabilidad: imputar interés cobrado a la fecha del pago (regla interés-primero sobre `payment_installment`) |
| `app/Services/Metrics/BusinessMetricsService.php` *(crear)* | Rentabilidad por periodo: interés + otros ingresos de utilidad − gastos ⇒ utilidad, margen, flujo de caja |
| `app/Console/Commands/VerifyInterestAttribution.php` *(crear)* | Verificación manual contra datos reales: derivado vs independiente + brecha del pivote |
| `app/Services/Dashboard/AdminDashboardMetricsService.php` *(modificar)* | Añadir `due_today_amount` al resumen operativo y aplicar polaridad conjunta |
| `app/Services/Dashboard/DashboardTrendService.php` *(modificar)* | Método puro `applyJointPolarity()` |
| `app/Http/Controllers/Api/Pwa/DashboardController.php` *(modificar)* | Payload del dueño: `due_today`, bloque de rentabilidad, exponer lo que ya se calcula |
| `app/Filament/Widgets/Admin/ProfitabilityWidget.php` *(crear)* | Mismos números de rentabilidad en el panel de escritorio |
| `resources/js/pwa/components/dashboard/MetricCard.vue` *(crear)* | Renderiza el contrato `Metric`: valor, estado y el porqué |
| `resources/js/pwa/components/home/AdminHome.vue` *(modificar)* | Reordenar bloques y consumir las métricas nuevas |
| `tests/Unit/MetricTest.php` *(crear)* | Contrato del value object |
| `tests/Feature/Metrics/InterestAttributionServiceTest.php` *(crear)* | Regla interés-primero, fechado y anulados |
| `tests/Feature/Metrics/BusinessMetricsServiceTest.php` *(crear)* | Utilidad, margen y **no doble conteo** |
| `tests/Feature/Dashboard/AdminPayloadTest.php` *(crear)* | `due_today` llega al dueño; bloque de rentabilidad presente |
| `tests/Unit/JointPolarityTest.php` *(crear)* | Cartera ▲ con caja ▼ ⇒ `watch` |

---

## Task 1: Value object `Metric` (contrato anti-ceros)

**Files:**
- Create: `app/Support/Metric.php`
- Test: `tests/Unit/MetricTest.php`

- [ ] **Step 1: Write the failing test**

```php
<?php

declare(strict_types=1);

namespace Tests\Unit;

use App\Support\Metric;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

class MetricTest extends TestCase
{
    #[Test]
    public function ok_lleva_valor_y_sin_nota(): void
    {
        $m = Metric::ok(1500.0)->toArray();

        $this->assertSame(1500.0, $m['value']);
        $this->assertSame('ok', $m['state']);
        $this->assertNull($m['note']);
    }

    #[Test]
    public function no_data_no_lleva_valor_y_explica_por_que(): void
    {
        $m = Metric::noData('sin snapshot de hace 7 dias')->toArray();

        $this->assertNull($m['value']);
        $this->assertSame('no_data', $m['state']);
        $this->assertSame('sin snapshot de hace 7 dias', $m['note']);
    }

    #[Test]
    public function unreliable_conserva_el_valor_pero_advierte(): void
    {
        $m = Metric::unreliable(98.2, 'no hay costos registrados en el periodo')->toArray();

        $this->assertSame(98.2, $m['value']);
        $this->assertSame('unreliable', $m['state']);
        $this->assertSame('no hay costos registrados en el periodo', $m['note']);
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `php artisan test --filter=MetricTest`
Expected: FAIL — `Class "App\Support\Metric" not found`

- [ ] **Step 3: Write the implementation**

```php
<?php

declare(strict_types=1);

namespace App\Support;

use Illuminate\Contracts\Support\Arrayable;

/**
 * Contrato anti-ceros del dashboard.
 *
 * Toda métrica viaja con su estado, nunca como un número pelado: si el dato no
 * existe o no es confiable, la UI muestra el porqué en vez de un "0" que miente.
 * Ver docs/superpowers/specs/2026-09-08-dashboard-salud-negocio-design.md §C.
 */
final class Metric implements Arrayable
{
    public const STATE_OK = 'ok';

    public const STATE_NO_DATA = 'no_data';

    public const STATE_UNRELIABLE = 'unreliable';

    /** @param array<string, mixed>|null $trend */
    private function __construct(
        public readonly ?float $value,
        public readonly string $state,
        public readonly ?string $note = null,
        public readonly ?array $trend = null,
    ) {}

    /** @param array<string, mixed>|null $trend */
    public static function ok(float $value, ?array $trend = null): self
    {
        return new self($value, self::STATE_OK, null, $trend);
    }

    /** No hay base para calcularlo. La UI dice "sin datos suficientes", nunca 0. */
    public static function noData(string $note): self
    {
        return new self(null, self::STATE_NO_DATA, $note);
    }

    /**
     * El dato existe pero engaña (p. ej. margen sin costos cargados).
     * Se muestra el valor JUNTO con la advertencia.
     *
     * @param array<string, mixed>|null $trend
     */
    public static function unreliable(float $value, string $note, ?array $trend = null): self
    {
        return new self($value, self::STATE_UNRELIABLE, $note, $trend);
    }

    /** @return array{value: float|null, state: string, note: string|null, trend: array<string, mixed>|null} */
    public function toArray(): array
    {
        return [
            'value' => $this->value,
            'state' => $this->state,
            'note' => $this->note,
            'trend' => $this->trend,
        ];
    }
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `php artisan test --filter=MetricTest`
Expected: PASS — 3 passed

- [ ] **Step 5: Commit**

```bash
git add app/Support/Metric.php tests/Unit/MetricTest.php
git commit -m "feat(dashboard): value object Metric para el contrato anti-ceros"
```

---

## Task 2: `InterestAttributionService` — imputación interés-primero

**Files:**
- Create: `app/Services/Metrics/InterestAttributionService.php`
- Test: `tests/Feature/Metrics/InterestAttributionServiceTest.php`

- [ ] **Step 1: Write the failing test**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Metrics;

use App\Models\Company;
use App\Models\Credit;
use App\Models\Installment;
use App\Models\Payment;
use App\Models\User;
use App\Services\Metrics\InterestAttributionService;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;

class InterestAttributionServiceTest extends TestCase
{
    use DatabaseTransactions;

    private Company $company;

    private Installment $installment;

    private User $user;

    protected function setUp(): void
    {
        parent::setUp();

        $this->company = Company::factory()->create(['status' => Company::STATUS_ACTIVE]);
        $this->user = User::factory()->create(['company_id' => $this->company->id]);

        $credit = Credit::factory()->create([
            'company_id' => $this->company->id,
            'status' => Credit::STATUS_ACTIVE,
        ]);

        // Cuota con $10.000 de interés y $50.000 de capital.
        $this->installment = Installment::factory()->create([
            'credit_id' => $credit->id,
            'interest_amount' => 10000,
            'principal_amount' => 50000,
            'total_amount' => 60000,
        ]);
    }

    /** Registra un pago aplicado a la cuota del setUp. */
    private function pay(float $amount, string $date, bool $voided = false): void
    {
        $payment = Payment::create([
            'company_id' => $this->company->id,
            'credit_id' => $this->installment->credit_id,
            'amount' => $amount,
            'payment_date' => $date,
            'registered_by_user_id' => $this->user->id,
            'voided' => $voided,
        ]);

        DB::table('payment_installment')->insert([
            'payment_id' => $payment->id,
            'installment_id' => $this->installment->id,
            'applied_amount' => $amount,
            'created_at' => now(),
            'updated_at' => now(),
        ]);
    }

    private function service(): InterestAttributionService
    {
        return app(InterestAttributionService::class);
    }

    #[Test]
    public function el_primer_pago_cubre_primero_el_interes(): void
    {
        $this->pay(25000, '2026-09-01');

        // De los $25.000 aplicados, los primeros $10.000 son interés.
        $this->assertEqualsWithDelta(10000.0, $this->service()->interestTotal($this->company->id), 0.01);
    }

    #[Test]
    public function el_segundo_pago_ya_es_solo_capital(): void
    {
        $this->pay(25000, '2026-09-01');
        $this->pay(20000, '2026-09-05');

        // El interés de la cuota ya se cubrió: el total no sube.
        $this->assertEqualsWithDelta(10000.0, $this->service()->interestTotal($this->company->id), 0.01);

        // Y el día del segundo pago no aporta interés.
        $this->assertEqualsWithDelta(
            0.0,
            $this->service()->interestBetween(
                $this->company->id,
                Carbon::parse('2026-09-05'),
                Carbon::parse('2026-09-05')
            ),
            0.01
        );
    }

    #[Test]
    public function el_interes_queda_fechado_en_el_pago_que_lo_genero(): void
    {
        $this->pay(25000, '2026-09-01');
        $this->pay(20000, '2026-09-05');

        $this->assertEqualsWithDelta(
            10000.0,
            $this->service()->interestBetween(
                $this->company->id,
                Carbon::parse('2026-09-01'),
                Carbon::parse('2026-09-01')
            ),
            0.01
        );
    }

    #[Test]
    public function los_pagos_anulados_no_cuentan(): void
    {
        $this->pay(25000, '2026-09-01', voided: true);

        $this->assertEqualsWithDelta(0.0, $this->service()->interestTotal($this->company->id), 0.01);
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `php artisan test --filter=InterestAttributionServiceTest`
Expected: FAIL — `Class "App\Services\Metrics\InterestAttributionService" not found`

- [ ] **Step 3: Write the implementation**

```php
<?php

declare(strict_types=1);

namespace App\Services\Metrics;

use Carbon\CarbonInterface;
use Illuminate\Support\Facades\DB;

/**
 * Imputa el interés cobrado a la fecha del pago que lo generó.
 *
 * REGLA: INTERÉS PRIMERO. Para cada cuota se recorren sus aplicaciones
 * (payment_installment.applied_amount) en orden de payments.payment_date; el
 * acumulado cubre primero el interest_amount de la cuota y el resto es capital.
 * Es la misma convención que ya usa `interest_earned`, así que no crea una
 * segunda verdad.
 *
 * NO lee de `incomes`: hoy los pagos se registran con la categoría genérica
 * `payment`, sin separar capital de interés — por eso "Utilidad mes" marcaba $0.
 * Ver docs/superpowers/specs/2026-09-08-dashboard-salud-negocio-design.md §A.
 */
class InterestAttributionService
{
    /** Interés cobrado entre dos fechas (ambas inclusive). */
    public function interestBetween(int $companyId, CarbonInterface $from, CarbonInterface $to): float
    {
        $row = DB::selectOne(
            $this->baseSql().' AND payment_date BETWEEN ? AND ?',
            [$companyId, $from->toDateString(), $to->toDateString()]
        );

        return (float) ($row->interes ?? 0.0);
    }

    /** Interés cobrado histórico, sin filtro de fecha. Lo usa credify:verify-interest. */
    public function interestTotal(int $companyId): float
    {
        $row = DB::selectOne($this->baseSql(), [$companyId]);

        return (float) ($row->interes ?? 0.0);
    }

    /**
     * El acumulado de la ventana se calcula sobre TODAS las aplicaciones de la
     * cuota; el filtro de fecha va afuera, para que un pago del periodo sepa
     * cuánto interés quedaba pendiente antes de él.
     */
    private function baseSql(): string
    {
        return <<<'SQL'
            WITH app AS (
                SELECT
                    p.payment_date,
                    pi.applied_amount,
                    i.interest_amount,
                    SUM(pi.applied_amount) OVER (
                        PARTITION BY pi.installment_id ORDER BY p.payment_date, pi.id
                    ) AS cum_after,
                    SUM(pi.applied_amount) OVER (
                        PARTITION BY pi.installment_id ORDER BY p.payment_date, pi.id
                    ) - pi.applied_amount AS cum_before
                FROM payment_installment pi
                INNER JOIN payments p     ON p.id = pi.payment_id AND p.voided = 0
                INNER JOIN installments i ON i.id = pi.installment_id
                INNER JOIN credits c      ON c.id = i.credit_id
                WHERE c.company_id = ?
            )
            SELECT COALESCE(SUM(
                GREATEST(0, LEAST(cum_after, interest_amount) - LEAST(cum_before, interest_amount))
            ), 0) AS interes
            FROM app
            WHERE 1 = 1
            SQL;
    }
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `php artisan test --filter=InterestAttributionServiceTest`
Expected: PASS — 4 passed

- [ ] **Step 5: Commit**

```bash
git add app/Services/Metrics/InterestAttributionService.php tests/Feature/Metrics/InterestAttributionServiceTest.php
git commit -m "feat(metrics): imputacion interes-primero sobre el ledger de cuotas"
```

---

## Task 3: Comando de verificación contra datos reales

Este comando es el anclaje del spec ($50.060.364). **No va en la suite** porque CI corre con BD limpia; se ejecuta a mano contra dev (copia de prod) o contra prod.

**Files:**
- Create: `app/Console/Commands/VerifyInterestAttribution.php`

- [ ] **Step 1: Write the implementation**

```php
<?php

declare(strict_types=1);

namespace App\Console\Commands;

use App\Models\Installment;
use App\Services\Metrics\InterestAttributionService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;

/**
 * Verifica la imputación de interés contra datos reales.
 *
 * Compara el interés derivado del pivote (regla interés-primero) contra el
 * calculado directamente sobre las cuotas — dos caminos independientes que
 * deben dar lo mismo. Para J&A Asociados (company 1) el valor esperado al
 * 2026-09-08 es $50.060.364.
 *
 * No es un test: CI corre con BD limpia. Correr a mano tras cambios en la
 * imputación o al sospechar de la cobertura del pivote.
 */
class VerifyInterestAttribution extends Command
{
    protected $signature = 'credify:verify-interest {--company=1 : ID de la empresa a verificar}';

    protected $description = 'Compara el interes derivado del pivote contra el calculado por cuotas y reporta la brecha';

    public function handle(InterestAttributionService $service): int
    {
        $companyId = (int) $this->option('company');

        $derivado = $service->interestTotal($companyId);

        // Camino independiente: mismo criterio que interest_earned.
        $row = DB::selectOne('
            SELECT COALESCE(SUM(
                CASE WHEN i.status = ? THEN i.interest_amount
                     ELSE LEAST(i.amount_paid, i.interest_amount) END
            ), 0) AS interes
            FROM installments i
            INNER JOIN credits c ON c.id = i.credit_id
            WHERE c.company_id = ?
        ', [Installment::STATUS_PAID, $companyId]);

        $porCuotas = (float) ($row->interes ?? 0.0);
        $brecha = $derivado - $porCuotas;

        $this->table(['Fuente', 'Interes'], [
            ['Derivado del pivote (interes-primero)', number_format($derivado, 2)],
            ['Calculado por cuotas (independiente)', number_format($porCuotas, 2)],
            ['Brecha', number_format($brecha, 2)],
        ]);

        if (abs($brecha) < 1.0) {
            $this->info('OK: los dos caminos cuadran. La imputacion es fiable.');

            return self::SUCCESS;
        }

        $this->warn(sprintf(
            'Brecha de %s (%.4f%%): hay pagos fuera de payment_installment o cuotas sin aplicaciones.',
            number_format($brecha, 2),
            $porCuotas > 0.0 ? ($brecha / $porCuotas) * 100 : 0.0
        ));

        return self::FAILURE;
    }
}
```

- [ ] **Step 2: Run it against the real data in dev**

Run: `php artisan credify:verify-interest --company=1`
Expected: tabla con `Derivado ≈ 50.060.364,00`, `Por cuotas ≈ 50.060.364,00`, `Brecha ≈ 0,00` y el mensaje `OK: los dos caminos cuadran.`

Si la brecha no es ~0, **detente**: la imputación no es fiable y el resto del plan se construye sobre arena.

- [ ] **Step 3: Commit**

```bash
git add app/Console/Commands/VerifyInterestAttribution.php
git commit -m "feat(metrics): comando credify:verify-interest para anclar la imputacion a datos reales"
```

---

## Task 4: `BusinessMetricsService` — rentabilidad por periodo

**Files:**
- Create: `app/Services/Metrics/BusinessMetricsService.php`
- Test: `tests/Feature/Metrics/BusinessMetricsServiceTest.php`

- [ ] **Step 1: Write the failing test**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Metrics;

use App\Models\Company;
use App\Models\Credit;
use App\Models\Expense;
use App\Models\Income;
use App\Models\Installment;
use App\Models\Payment;
use App\Models\User;
use App\Services\Metrics\BusinessMetricsService;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;

class BusinessMetricsServiceTest extends TestCase
{
    use DatabaseTransactions;

    private Company $company;

    private User $user;

    private Installment $installment;

    protected function setUp(): void
    {
        parent::setUp();

        $this->company = Company::factory()->create(['status' => Company::STATUS_ACTIVE]);
        $this->user = User::factory()->create(['company_id' => $this->company->id]);

        $credit = Credit::factory()->create([
            'company_id' => $this->company->id,
            'status' => Credit::STATUS_ACTIVE,
        ]);

        $this->installment = Installment::factory()->create([
            'credit_id' => $credit->id,
            'interest_amount' => 10000,
            'principal_amount' => 50000,
            'total_amount' => 60000,
        ]);

        // Pago que cubre el interés completo ($10.000) el 2026-09-01.
        $payment = Payment::create([
            'company_id' => $this->company->id,
            'credit_id' => $credit->id,
            'amount' => 25000,
            'payment_date' => '2026-09-01',
            'registered_by_user_id' => $this->user->id,
            'voided' => false,
        ]);

        DB::table('payment_installment')->insert([
            'payment_id' => $payment->id,
            'installment_id' => $this->installment->id,
            'applied_amount' => 25000,
            'created_at' => now(),
            'updated_at' => now(),
        ]);
    }

    /** @return array<string, mixed> */
    private function metrics(): array
    {
        return app(BusinessMetricsService::class)->forPeriod(
            $this->company->id,
            Carbon::parse('2026-09-01'),
            Carbon::parse('2026-09-30'),
        );
    }

    #[Test]
    public function la_utilidad_es_interes_menos_gastos_operativos(): void
    {
        Expense::create([
            'company_id' => $this->company->id,
            'category' => 'salary_wages',
            'amount' => 4000,
            'operation_date' => '2026-09-02',
            'affects_cash' => true,
            'affects_profit' => true,
            'requires_approval' => false,
            'description' => 'Sueldo de prueba',
        ]);

        $m = $this->metrics();

        $this->assertEqualsWithDelta(10000.0, $m['interest_collected'], 0.01);
        $this->assertEqualsWithDelta(4000.0, $m['operating_expenses'], 0.01);
        $this->assertEqualsWithDelta(6000.0, $m['net_profit'], 0.01);
        $this->assertEqualsWithDelta(60.0, $m['margin'], 0.01);
    }

    #[Test]
    public function un_ingreso_legacy_payment_no_se_cuenta_dos_veces(): void
    {
        // El pago YA está representado por la derivación del ledger de cuotas.
        // Si además sumáramos su fila en `incomes`, la utilidad se dispararía.
        Income::create([
            'company_id' => $this->company->id,
            'category' => 'payment',
            'amount' => 25000,
            'operation_date' => '2026-09-01',
            'description' => 'Ingreso legacy del mismo pago',
        ]);

        $m = $this->metrics();

        $this->assertEqualsWithDelta(0.0, $m['other_profit_income'], 0.01);
        $this->assertEqualsWithDelta(10000.0, $m['profit_income'], 0.01);
        $this->assertEqualsWithDelta(10000.0, $m['net_profit'], 0.01);
    }

    #[Test]
    public function las_comisiones_bien_categorizadas_si_suman(): void
    {
        Income::create([
            'company_id' => $this->company->id,
            'category' => 'late_fee',
            'amount' => 2000,
            'operation_date' => '2026-09-03',
            'description' => 'Recargo por mora',
        ]);

        $m = $this->metrics();

        $this->assertEqualsWithDelta(2000.0, $m['other_profit_income'], 0.01);
        $this->assertEqualsWithDelta(12000.0, $m['profit_income'], 0.01);
    }

    #[Test]
    public function sin_ingresos_de_utilidad_el_margen_es_null_no_cero(): void
    {
        $m = app(BusinessMetricsService::class)->forPeriod(
            $this->company->id,
            Carbon::parse('2026-01-01'),
            Carbon::parse('2026-01-31'),
        );

        $this->assertEqualsWithDelta(0.0, $m['profit_income'], 0.01);
        $this->assertNull($m['margin']);
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `php artisan test --filter=BusinessMetricsServiceTest`
Expected: FAIL — `Class "App\Services\Metrics\BusinessMetricsService" not found`

- [ ] **Step 3: Write the implementation**

```php
<?php

declare(strict_types=1);

namespace App\Services\Metrics;

use App\Enums\IncomeCategory;
use App\Models\Expense;
use App\Models\Income;
use Carbon\CarbonInterface;

/**
 * Rentabilidad por periodo (flujo).
 *
 * El interés viene de InterestAttributionService (ledger de cuotas), NO de
 * `incomes`, porque los pagos se registran con la categoría genérica `payment`.
 * Para no contar dos veces, los ingresos de `incomes` excluyen `payment` y
 * `loan_payment_interest`.
 *
 * Ver docs/superpowers/specs/2026-09-08-dashboard-salud-negocio-design.md §A.
 */
class BusinessMetricsService
{
    public function __construct(
        private readonly InterestAttributionService $interest,
    ) {}

    /**
     * @return array{
     *     interest_collected: float, other_profit_income: float, profit_income: float,
     *     operating_expenses: float, net_profit: float, margin: float|null, cash_flow: float
     * }
     */
    public function forPeriod(int $companyId, CarbonInterface $from, CarbonInterface $to): array
    {
        $interestCollected = $this->interest->interestBetween($companyId, $from, $to);
        $otherProfitIncome = $this->otherProfitIncome($companyId, $from, $to);
        $profitIncome = $interestCollected + $otherProfitIncome;
        $operatingExpenses = $this->operatingExpenses($companyId, $from, $to);
        $netProfit = $profitIncome - $operatingExpenses;

        return [
            'interest_collected' => $interestCollected,
            'other_profit_income' => $otherProfitIncome,
            'profit_income' => $profitIncome,
            'operating_expenses' => $operatingExpenses,
            'net_profit' => $netProfit,
            'margin' => $profitIncome > 0.0 ? ($netProfit / $profitIncome) * 100 : null,
            'cash_flow' => $this->cashFlow($companyId, $from, $to),
        ];
    }

    /**
     * Categorías de `incomes` que aportan utilidad SIN duplicar lo derivado.
     * Se excluye `payment` (ya viene por derivación) y `loan_payment_interest`
     * (lo duplicaría el día que alguien empiece a escribirlo).
     *
     * @return list<string>
     */
    public static function profitCategoriesFromLedger(): array
    {
        $excluded = [IncomeCategory::PAYMENT, IncomeCategory::LOAN_PAYMENT_INTEREST];

        return array_values(array_map(
            static fn (IncomeCategory $c): string => $c->value,
            array_filter(
                IncomeCategory::cases(),
                static fn (IncomeCategory $c): bool => $c->affectsProfit() && ! in_array($c, $excluded, true),
            )
        ));
    }

    private function otherProfitIncome(int $companyId, CarbonInterface $from, CarbonInterface $to): float
    {
        return (float) Income::query()
            ->withoutGlobalScopes()
            ->where('company_id', $companyId)
            ->whereIn('category', self::profitCategoriesFromLedger())
            ->whereBetween('operation_date', [$from->toDateString(), $to->toDateString()])
            ->sum('amount');
    }

    private function operatingExpenses(int $companyId, CarbonInterface $from, CarbonInterface $to): float
    {
        return (float) Expense::query()
            ->withoutGlobalScopes()
            ->where('company_id', $companyId)
            ->where('affects_profit', true)
            ->effective()
            ->whereBetween('operation_date', [$from->toDateString(), $to->toDateString()])
            ->sum('amount');
    }

    /** Movimiento de efectivo, NO utilidad: incluye capital y desembolsos. */
    private function cashFlow(int $companyId, CarbonInterface $from, CarbonInterface $to): float
    {
        $in = (float) Income::query()
            ->withoutGlobalScopes()
            ->where('company_id', $companyId)
            ->whereBetween('operation_date', [$from->toDateString(), $to->toDateString()])
            ->sum('amount');

        $out = (float) Expense::query()
            ->withoutGlobalScopes()
            ->where('company_id', $companyId)
            ->where('affects_cash', true)
            ->effective()
            ->whereBetween('operation_date', [$from->toDateString(), $to->toDateString()])
            ->sum('amount');

        return $in - $out;
    }
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `php artisan test --filter=BusinessMetricsServiceTest`
Expected: PASS — 4 passed

> Si `Expense::create` o `Income::create` fallan por una columna NOT NULL que falta, añádela al array del test con un valor obvio (`'description' => '…'`) — no cambies el servicio.

- [ ] **Step 5: Commit**

```bash
git add app/Services/Metrics/BusinessMetricsService.php tests/Feature/Metrics/BusinessMetricsServiceTest.php
git commit -m "feat(metrics): BusinessMetricsService con utilidad, margen y flujo por periodo"
```

---

## Task 5: Arreglar la meta del día del dueño

`due_today` solo se setea en la rama del cobrador (`DashboardController.php:84`), así que la meta y su barra de progreso del dueño están **siempre en 0**. El conteo ya existe en `getOperationalSummary`; falta el monto.

**Files:**
- Modify: `app/Services/Dashboard/AdminDashboardMetricsService.php:311-360`
- Modify: `app/Http/Controllers/Api/Pwa/DashboardController.php:232-273`
- Test: `tests/Feature/Dashboard/AdminPayloadTest.php`

- [ ] **Step 1: Write the failing test**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Dashboard;

use App\Models\Company;
use App\Models\Credit;
use App\Models\Installment;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;

class AdminPayloadTest extends TestCase
{
    use DatabaseTransactions;

    #[Test]
    public function el_dueno_recibe_la_meta_del_dia(): void
    {
        $company = Company::factory()->create(['status' => Company::STATUS_ACTIVE]);
        $admin = User::factory()->create(['company_id' => $company->id]);
        $admin->assignRole('admin');

        $credit = Credit::factory()->create([
            'company_id' => $company->id,
            'status' => Credit::STATUS_ACTIVE,
        ]);

        // Cuota que vence HOY por $60.000, sin pagar.
        Installment::factory()->create([
            'credit_id' => $credit->id,
            'due_date' => now()->toDateString(),
            'total_amount' => 60000,
            'amount_paid' => 0,
            'status' => Installment::STATUS_PENDING,
        ]);

        $response = $this->actingAs($admin)->getJson('/api/pwa/dashboard');

        $response->assertOk();
        $this->assertSame('admin', $response->json('role'));
        $this->assertEqualsWithDelta(60000.0, (float) $response->json('stats.due_today.amount'), 0.01);
        $this->assertSame(1, (int) $response->json('stats.due_today.count'));
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `php artisan test --filter=AdminPayloadTest`
Expected: FAIL — `stats.due_today.amount` es null (la clave no existe en el payload del dueño)

- [ ] **Step 3: Añadir el monto al resumen operativo**

En `app/Services/Dashboard/AdminDashboardMetricsService.php`, dentro de `getOperationalSummary()`, añade el `SUM` del saldo pendiente de las cuotas que vencen hoy como **primera** columna del `SELECT`:

```sql
SUM(CASE WHEN i.due_date = ? AND i.status IN (?, ?, ?) THEN (i.total_amount - i.amount_paid) ELSE 0 END) as due_today_amount,
```

y antepón sus parámetros al array de bindings (mismo orden que la columna):

```php
$today,
Installment::STATUS_PENDING, Installment::STATUS_PARTIAL_PAID, Installment::STATUS_OVERDUE,
```

Luego añade la clave al array de retorno:

```php
return [
    'due_today_amount' => (float) ($result->due_today_amount ?? 0),
    'due_today_count' => $pendingToday,
    'paid_today_count' => $paidToday,
    'overdue_count' => (int) ($result->overdue_count ?? 0),
    'active_collectors' => $activeCollectors,
    'collection_rate_today' => $collectionRate,
];
```

- [ ] **Step 4: Exponerlo en el payload del dueño**

En `app/Http/Controllers/Api/Pwa/DashboardController.php`, dentro de `adminDashboard()`, añade al array `stats` (junto a las claves existentes):

```php
// #dashboard-salud: el dueño también necesita su meta del día. Antes solo la
// recibía el cobrador, por eso su barra de progreso estaba siempre en 0.
'due_today' => [
    'amount' => $metrics['operational_summary']['due_today_amount'],
    'count' => $metrics['operational_summary']['due_today_count'],
],
```

- [ ] **Step 5: Run test to verify it passes**

Run: `php artisan test --filter=AdminPayloadTest`
Expected: PASS — 1 passed

- [ ] **Step 6: Commit**

```bash
git add app/Services/Dashboard/AdminDashboardMetricsService.php app/Http/Controllers/Api/Pwa/DashboardController.php tests/Feature/Dashboard/AdminPayloadTest.php
git commit -m "fix(dashboard): la meta del dia del dueno ya no queda en cero"
```

---

## Task 6: Polaridad conjunta — una cartera que crece con la caja cayendo no es verde

**Files:**
- Modify: `app/Services/Dashboard/DashboardTrendService.php`
- Test: `tests/Unit/JointPolarityTest.php`

- [ ] **Step 1: Write the failing test**

```php
<?php

declare(strict_types=1);

namespace Tests\Unit;

use App\Services\Dashboard\DashboardTrendService;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

class JointPolarityTest extends TestCase
{
    #[Test]
    public function cartera_creciendo_con_caja_cayendo_es_watch(): void
    {
        $trends = DashboardTrendService::applyJointPolarity([
            'por_cobrar' => ['trend_pct' => 3.3, 'good' => true],
            'cash_base' => ['trend_pct' => -57.5, 'good' => false],
            'delinquency_rate' => ['trend_pct' => 9.0, 'good' => false],
        ]);

        $this->assertSame('watch', $trends['por_cobrar']['state']);
        $this->assertNull($trends['por_cobrar']['good']);
    }

    #[Test]
    public function cartera_creciendo_con_caja_sana_sigue_siendo_buena(): void
    {
        $trends = DashboardTrendService::applyJointPolarity([
            'por_cobrar' => ['trend_pct' => 3.3, 'good' => true],
            'cash_base' => ['trend_pct' => 5.0, 'good' => true],
            'delinquency_rate' => ['trend_pct' => -2.0, 'good' => true],
        ]);

        $this->assertSame('good', $trends['por_cobrar']['state']);
        $this->assertTrue($trends['por_cobrar']['good']);
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `php artisan test --filter=JointPolarityTest`
Expected: FAIL — `Call to undefined method ...::applyJointPolarity()`

- [ ] **Step 3: Write the implementation**

Añade a `app/Services/Dashboard/DashboardTrendService.php`:

```php
/**
 * Polaridad conjunta.
 *
 * `por_cobrar` creciente se pintaba verde siempre. Pero una cartera que crece
 * mientras la caja baja o la mora sube no es un logro: es riesgo. En ese caso
 * el estado pasa a `watch` (ámbar) y deja de afirmar que va bien.
 *
 * Ver docs/superpowers/specs/2026-09-08-dashboard-salud-negocio-design.md §D.
 *
 * @param  array<string, array{trend_pct: float|null, good: bool|null}>  $trends
 * @return array<string, array{trend_pct: float|null, good: bool|null, state: string}>
 */
public static function applyJointPolarity(array $trends): array
{
    foreach ($trends as $key => $trend) {
        $trends[$key]['state'] = ($trend['good'] ?? false) ? 'good' : 'bad';
    }

    $carteraSube = ($trends['por_cobrar']['trend_pct'] ?? 0.0) > 0.0;
    $cajaBaja = ($trends['cash_base']['trend_pct'] ?? 0.0) < 0.0;
    $moraSube = ($trends['delinquency_rate']['trend_pct'] ?? 0.0) > 0.0;

    if ($carteraSube && ($cajaBaja || $moraSube)) {
        $trends['por_cobrar']['good'] = null;
        $trends['por_cobrar']['state'] = 'watch';
    }

    return $trends;
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `php artisan test --filter=JointPolarityTest`
Expected: PASS — 2 passed

- [ ] **Step 5: Aplicarlo en el servicio del dueño**

En `app/Services/Dashboard/AdminDashboardMetricsService.php`, justo después de obtener las tendencias (donde hoy se aplanan a `{trend_pct, good}`, ~línea 96-107), pasa el arreglo por el filtro:

```php
$trends = DashboardTrendService::applyJointPolarity($trends);
```

- [ ] **Step 6: Run the dashboard tests**

Run: `php artisan test --filter="DashboardTrendServiceTest|AdminPayloadTest|JointPolarityTest"`
Expected: PASS — todas verdes

- [ ] **Step 7: Commit**

```bash
git add app/Services/Dashboard/DashboardTrendService.php app/Services/Dashboard/AdminDashboardMetricsService.php tests/Unit/JointPolarityTest.php
git commit -m "fix(dashboard): polaridad conjunta - cartera al alza con caja a la baja es watch"
```

---

## Task 7: Bloque de rentabilidad en el payload

**Files:**
- Modify: `app/Http/Controllers/Api/Pwa/DashboardController.php` (método `adminDashboard`)
- Test: `tests/Feature/Dashboard/AdminPayloadTest.php` (añadir caso)

- [ ] **Step 1: Write the failing test**

Añade a `tests/Feature/Dashboard/AdminPayloadTest.php`:

```php
    #[Test]
    public function el_dueno_recibe_el_bloque_de_rentabilidad_con_estados(): void
    {
        $company = Company::factory()->create(['status' => Company::STATUS_ACTIVE]);
        $admin = User::factory()->create(['company_id' => $company->id]);
        $admin->assignRole('admin');

        $response = $this->actingAs($admin)->getJson('/api/pwa/dashboard');

        $response->assertOk();

        // Sin ingresos de utilidad en el periodo el margen NO puede ser 0: es no_data.
        $this->assertSame('no_data', $response->json('stats.profitability.margin.state'));
        $this->assertNull($response->json('stats.profitability.margin.value'));

        // La utilidad neta sí es un 0 legítimo.
        $this->assertSame('ok', $response->json('stats.profitability.net_profit.state'));
        $this->assertEqualsWithDelta(0.0, (float) $response->json('stats.profitability.net_profit.value'), 0.01);
    }
```

- [ ] **Step 2: Run test to verify it fails**

Run: `php artisan test --filter=AdminPayloadTest`
Expected: FAIL — `stats.profitability.margin.state` es null

- [ ] **Step 3: Write the implementation**

En `DashboardController`, inyecta el servicio en el constructor:

```php
private BusinessMetricsService $businessMetrics,
```

y en `adminDashboard()`, antes del `return`, arma el bloque:

```php
// #dashboard-salud §B bloque 2: rentabilidad real, ventana rolling de 30 días
// contra los 30 previos. El interés sale del ledger de cuotas, no de `incomes`.
$hoy = Carbon::today();
$actual = $this->businessMetrics->forPeriod($companyId, $hoy->copy()->subDays(29), $hoy);
$previo = $this->businessMetrics->forPeriod($companyId, $hoy->copy()->subDays(59), $hoy->copy()->subDays(30));

$deltaPct = static fn (float $ahora, float $antes): ?float => $antes > 0.0
    ? round((($ahora - $antes) / $antes) * 100, 1)
    : null;

$margen = $actual['margin'] === null
    ? Metric::noData('sin ingresos de utilidad en el periodo')
    : ($actual['operating_expenses'] <= 0.0
        ? Metric::unreliable($actual['margin'], 'no hay costos registrados en el periodo')
        : Metric::ok($actual['margin']));

$patrimonio = (float) ($metrics['business_total']['amount'] ?? 0);

$profitability = [
    'net_profit' => Metric::ok($actual['net_profit'], [
        'trend_pct' => $deltaPct($actual['net_profit'], $previo['net_profit']),
    ])->toArray(),
    'interest_collected' => Metric::ok($actual['interest_collected'], [
        'trend_pct' => $deltaPct($actual['interest_collected'], $previo['interest_collected']),
    ])->toArray(),
    'margin' => $margen->toArray(),
    'return_on_equity' => $patrimonio > 0.0
        ? Metric::ok(round(($actual['net_profit'] / $patrimonio) * 100, 1))->toArray()
        : Metric::noData('sin patrimonio para calcular el rendimiento')->toArray(),
    'cash_flow' => Metric::ok($actual['cash_flow'])->toArray(),
    'window' => ['from' => $hoy->copy()->subDays(29)->toDateString(), 'to' => $hoy->toDateString()],
];
```

y añade `'profitability' => $profitability,` al array `stats`.

Imports necesarios en el controlador:

```php
use App\Models\Payment;
use App\Services\Metrics\BusinessMetricsService;
use App\Support\Metric;
use Carbon\Carbon;
```

- [ ] **Step 3b: Bloque 4 — cortes de ritmo y sparkline de 30 días**

El sparkline hoy se arma con 7 días; el spec §B bloque 4 pide 30. En `adminDashboard()`, donde se construye `collections_trend`, cambia la ventana:

```php
// #dashboard-salud §B bloque 4: 30 días de historia, no 7.
$trend = $this->collectionMetrics->getTeamCollectionTrend($companyId, 30, null);
```

Y añade los cortes de mes, justo después del bloque de rentabilidad:

```php
// #dashboard-salud §B bloque 4: "mes en curso" se muestra SIEMPRE con los días
// transcurridos, para que 8 de 30 días no se lea como una caída del 72%.
$inicioMes = $hoy->copy()->startOfMonth();
$inicioMesPasado = $hoy->copy()->subMonthNoOverflow()->startOfMonth();
$finMesPasado = $inicioMes->copy()->subDay();

$cobradoEntre = static fn (string $desde, string $hasta): float => (float) Payment::query()
    ->withoutGlobalScopes()
    ->where('company_id', $companyId)
    ->where('voided', false)
    ->whereBetween('payment_date', [$desde, $hasta])
    ->sum('amount');

$ritmo = [
    'month_to_date' => [
        'amount' => $cobradoEntre($inicioMes->toDateString(), $hoy->toDateString()),
        'days_elapsed' => $inicioMes->diffInDays($hoy) + 1,
        'days_in_month' => $hoy->daysInMonth,
    ],
    'last_month' => [
        'amount' => $cobradoEntre($inicioMesPasado->toDateString(), $finMesPasado->toDateString()),
        'label' => $inicioMesPasado->translatedFormat('F'),
    ],
];
```

y añade `'ritmo' => $ritmo,` al array `stats`.

- [ ] **Step 4: Run test to verify it passes**

Run: `php artisan test --filter=AdminPayloadTest`
Expected: PASS — 2 passed

- [ ] **Step 5: Comprobar contra datos reales en dev**

Run: `php artisan tinker --execute='$c=app(\App\Services\Metrics\BusinessMetricsService::class); $h=\Carbon\Carbon::today(); print_r($c->forPeriod(1, $h->copy()->subDays(29), $h));'`
Expected: `interest_collected` ≈ 5.656.701, `operating_expenses` ≈ 100.000, `net_profit` ≈ 5.556.701 (los valores se mueven con los pagos del día).

- [ ] **Step 6: Commit**

```bash
git add app/Http/Controllers/Api/Pwa/DashboardController.php tests/Feature/Dashboard/AdminPayloadTest.php
git commit -m "feat(dashboard): bloque de rentabilidad real en el payload del dueno"
```

---

## Task 8: PWA — `MetricCard` y reordenar el home del dueño

**Files:**
- Create: `resources/js/pwa/components/dashboard/MetricCard.vue`
- Modify: `resources/js/pwa/components/home/AdminHome.vue`

- [ ] **Step 1: Crear el componente que respeta el contrato**

```vue
<script setup>
/**
 * Renderiza una métrica del contrato anti-ceros.
 * Nunca muestra un "0" pelado: si el estado es no_data explica por qué, y si es
 * unreliable muestra el valor CON su advertencia.
 */
import { computed } from 'vue'

const props = defineProps({
    label: { type: String, required: true },
    metric: { type: Object, default: () => ({}) },
    format: { type: Function, default: (v) => v },
})

const state = computed(() => props.metric?.state ?? 'no_data')
const hasValue = computed(() => state.value !== 'no_data' && props.metric?.value !== null)
const trendPct = computed(() => props.metric?.trend?.trend_pct ?? null)
</script>

<template>
    <div class="pwa-card flex flex-col gap-1 p-4">
        <span class="text-xs text-slate-400">{{ label }}</span>

        <span v-if="hasValue" class="text-2xl font-semibold tabular-nums">
            {{ format(metric.value) }}
        </span>
        <span v-else class="text-sm text-slate-400 italic">sin datos suficientes</span>

        <span
            v-if="trendPct !== null"
            class="text-xs font-medium tabular-nums"
            :class="trendPct >= 0 ? 'text-emerald-400' : 'text-rose-400'"
        >
            {{ trendPct >= 0 ? '▲' : '▼' }} {{ Math.abs(trendPct) }}%
        </span>

        <p v-if="metric?.note" class="mt-1 text-xs leading-snug" :class="state === 'unreliable' ? 'text-amber-400' : 'text-slate-400'">
            {{ metric.note }}
        </p>
    </div>
</template>
```

- [ ] **Step 2: Reordenar los bloques en `AdminHome.vue`**

El orden final (decisión del usuario) es: **1 Patrimonio y caja → 2 Rentabilidad → 3 Cartera y riesgo → 4 Ritmo**.

En el template de `AdminHome.vue`:
1. Mueve el carrusel de KPIs para que su primera tarjeta sea **Patrimonio** y la segunda **Caja** (hoy la segunda es "Utilidad mes").
2. Sustituye la tarjeta **"Utilidad mes"** por un bloque nuevo de rentabilidad usando `MetricCard`, alimentado por `stats.profitability`:

```vue
<section class="mt-4 grid grid-cols-2 gap-3">
    <MetricCard label="Utilidad neta · 30 días" :metric="stats.profitability?.net_profit" :format="money" />
    <MetricCard label="Interés cobrado" :metric="stats.profitability?.interest_collected" :format="money" />
    <MetricCard label="Margen" :metric="stats.profitability?.margin" :format="pct" />
    <MetricCard label="Rendimiento mensual" :metric="stats.profitability?.return_on_equity" :format="pct" />
</section>
```

3. Importa el componente y define los formateadores junto a los existentes:

```js
import MetricCard from '../dashboard/MetricCard.vue'

const money = (v) => formatCurrency(v)   // reutiliza el formateador de moneda ya usado en la vista
const pct = (v) => `${Number(v).toFixed(1)}%`
```

- [ ] **Step 2b: Bloque 4 — mostrar el ritmo y lo que estaba oculto**

`operational_summary` ya viaja en el payload y **nadie lo muestra** (spec §E). Añade, dentro de la sección "Hoy" de `AdminHome.vue`, la tasa de cobro y las cuotas vencidas, y debajo los cortes de mes:

```vue
<div class="mt-3 grid grid-cols-2 gap-3">
    <div class="pwa-card p-3">
        <span class="text-xs text-slate-400">Tasa de cobro hoy</span>
        <p class="text-lg font-semibold tabular-nums">
            {{ stats.operational_summary?.collection_rate_today ?? 0 }}%
        </p>
        <span class="text-xs text-slate-400">
            {{ stats.operational_summary?.paid_today_count ?? 0 }} de
            {{ (stats.operational_summary?.paid_today_count ?? 0) + (stats.operational_summary?.due_today_count ?? 0) }} cuotas
        </span>
    </div>
    <div class="pwa-card p-3">
        <span class="text-xs text-slate-400">Cuotas vencidas</span>
        <p class="text-lg font-semibold tabular-nums text-rose-400">
            {{ stats.operational_summary?.overdue_count ?? 0 }}
        </p>
        <span class="text-xs text-slate-400">
            {{ stats.operational_summary?.active_collectors ?? 0 }} cobrador(es) activo(s)
        </span>
    </div>
</div>

<div class="mt-3 grid grid-cols-2 gap-3">
    <div class="pwa-card p-3">
        <span class="text-xs text-slate-400">Mes en curso</span>
        <p class="text-lg font-semibold tabular-nums">{{ money(stats.ritmo?.month_to_date?.amount ?? 0) }}</p>
        <span class="text-xs text-slate-400">
            van {{ stats.ritmo?.month_to_date?.days_elapsed ?? 0 }} de
            {{ stats.ritmo?.month_to_date?.days_in_month ?? 0 }} días
        </span>
    </div>
    <div class="pwa-card p-3">
        <span class="text-xs text-slate-400">Cierre mes pasado</span>
        <p class="text-lg font-semibold tabular-nums">{{ money(stats.ritmo?.last_month?.amount ?? 0) }}</p>
        <span class="text-xs text-slate-400">{{ stats.ritmo?.last_month?.label ?? '' }} completo</span>
    </div>
</div>
```

El "van N de M días" **no es decorativo**: es lo que evita que el mes en curso se lea como un desplome al día 8.

- [ ] **Step 3: Verificar que compila**

Run: `npm run build`
Expected: `✓ built in …` sin errores

- [ ] **Step 4: Verificar en el navegador**

Run: `php artisan serve --host=127.0.0.1 --port=8125` y abre `http://127.0.0.1:8125/pwa/login`; entra como dueño con las credenciales que imprime `php artisan credify:e2e-prepare`.
Expected: el home abre con **Patrimonio** primero, el bloque de rentabilidad muestra la utilidad real (no $0) y el margen aparece con su advertencia ámbar de "no hay costos registrados".

- [ ] **Step 5: Commit**

```bash
git add resources/js/pwa/components/dashboard/MetricCard.vue resources/js/pwa/components/home/AdminHome.vue
git commit -m "feat(pwa): home del dueno reordenado con bloque de rentabilidad real"
```

---

## Task 9: Filament — widget de rentabilidad

Sigue el patrón de `app/Filament/Widgets/Admin/BusinessKPIsWidget.php` (StatsOverviewWidget, `canView()` gateado a admin no-superadmin, `columnSpan = 'full'`).

**Files:**
- Create: `app/Filament/Widgets/Admin/ProfitabilityWidget.php`

- [ ] **Step 1: Write the implementation**

```php
<?php

declare(strict_types=1);

namespace App\Filament\Widgets\Admin;

use App\Services\Metrics\BusinessMetricsService;
use Carbon\Carbon;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Support\Facades\Auth;

/**
 * Rentabilidad real del negocio — mismos números que la PWA del dueño.
 *
 * Lee del mismo BusinessMetricsService que el payload de la app, así que las dos
 * superficies no pueden mostrar cifras distintas.
 */
class ProfitabilityWidget extends BaseWidget
{
    protected ?string $pollingInterval = '120s';

    protected static ?int $sort = 1;

    protected ?string $heading = 'Rentabilidad (últimos 30 días)';

    protected int|string|array $columnSpan = 'full';

    public static function canView(): bool
    {
        $user = Auth::user();

        return $user && $user->hasRole('admin') && ! $user->isSuperAdmin();
    }

    protected function getStats(): array
    {
        $companyId = (int) Auth::user()->company_id;
        $hoy = Carbon::today();

        $service = app(BusinessMetricsService::class);
        $actual = $service->forPeriod($companyId, $hoy->copy()->subDays(29), $hoy);
        $previo = $service->forPeriod($companyId, $hoy->copy()->subDays(59), $hoy->copy()->subDays(30));

        $delta = $previo['net_profit'] > 0.0
            ? round((($actual['net_profit'] - $previo['net_profit']) / $previo['net_profit']) * 100, 1)
            : null;

        $margenDesc = $actual['operating_expenses'] <= 0.0
            ? 'sin costos registrados en el periodo'
            : 'utilidad sobre ingresos';

        return [
            Stat::make('Utilidad neta', '$'.number_format($actual['net_profit'], 0, ',', '.'))
                ->description($delta === null ? 'sin base de comparación' : sprintf('%s%s%% vs 30 días previos', $delta >= 0 ? '▲ ' : '▼ ', abs($delta)))
                ->color($delta !== null && $delta >= 0 ? 'success' : 'danger'),

            Stat::make('Interés cobrado', '$'.number_format($actual['interest_collected'], 0, ',', '.'))
                ->description('ganancia bruta de la cartera')
                ->color('success'),

            Stat::make('Margen', $actual['margin'] === null ? 'sin datos' : number_format($actual['margin'], 1, ',', '.').'%')
                ->description($margenDesc)
                ->color($actual['operating_expenses'] <= 0.0 ? 'warning' : 'success'),
        ];
    }
}
```

- [ ] **Step 2: Verificar en el panel**

Run: abre `http://127.0.0.1:8125/admin` como dueño.
Expected: bajo los KPIs globales aparece "Rentabilidad (últimos 30 días)" con **los mismos tres números** que muestra la PWA. El margen sale en ámbar con "sin costos registrados en el periodo".

- [ ] **Step 3: Commit**

```bash
git add app/Filament/Widgets/Admin/ProfitabilityWidget.php
git commit -m "feat(filament): widget de rentabilidad con los mismos numeros que la PWA"
```

---

## Task 10: CHANGELOG y verificación final

**Files:**
- Modify: `CHANGELOG.md`

- [ ] **Step 1: Añadir la entrada al CHANGELOG**

Bajo `### Añadido` en `[Sin publicar]`, como primer bullet:

```markdown
- **Dashboard de salud del negocio para el dueño (PWA + Filament).** El panel del dueño mostraba "Utilidad mes" en **$0** porque la métrica busca la categoría `loan_payment_interest`, que **ningún servicio escribe** (los 2.272 pagos de la empresa real se registran como `payment` genérico, 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 y flujo de caja por periodo — con **exclusión explícita de `payment` y `loan_payment_interest`** para no contar dos veces. 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`: el margen sin costos cargados avisa en vez de fingir 98%), y se corrigen dos engaños del panel viejo: la **meta del día del dueño** (que estaba siempre en 0 porque el backend solo la mandaba al cobrador) y la **polaridad** de "por cobrar" (que se pintaba verde aunque la caja cayera y la mora subiera). Las dos superficies leen del mismo servicio. Verificación contra datos reales con `credify:verify-interest` (interés histórico derivado = $50.060.364, cuadra exacto con el cálculo independiente). **Sin migraciones.** Ver `docs/superpowers/specs/2026-09-08-dashboard-salud-negocio-design.md`.
```

- [ ] **Step 2: Suite completa + preflight**

```bash
vendor/bin/pint app/ tests/
vendor/bin/phpstan analyse --level=5 app/ --no-progress
php artisan test
```
Expected: Pint PASS · PHPStan `[OK] No errors` · suite verde.

> Si falla `SnapshotDashboardMetricsTest` por `assertDatabaseCount` sobre la tabla completa, es el flaky conocido de la BD compartida (ahora con datos de prod), **no** tu cambio: `mysql -h127.0.0.1 -ucredifyuser -pcredifypass credify -e "DELETE FROM dashboard_snapshots;"` y vuelve a correr.

- [ ] **Step 3: Verificación de anclaje contra datos reales**

```bash
php artisan credify:verify-interest --company=1
```
Expected: `OK: los dos caminos cuadran.`

- [ ] **Step 4: Build del frontend**

```bash
npm run build
```
Expected: `✓ built` sin errores y sin `.map` en `public/build`.

- [ ] **Step 5: E2E siguen verdes**

```bash
npx playwright test
```
Expected: 8 passed.

- [ ] **Step 6: Commit**

```bash
git add CHANGELOG.md
git commit -m "docs(changelog): dashboard de salud del negocio"
```

---

## Notas de despliegue

Cambia backend + assets de la PWA, **sin migraciones**. Al desplegar:

- Build con **`VITE_SENTRY_DSN` y `VITE_POSTHOG_KEY`** en el entorno, o esas integraciones quedan apagadas en el bundle (ver `credify-dev-tooling`).
- Swap de assets usando **`sudo -n rm -rf public/build.old`** (gotcha de `www-data`, ver `credify-prod-vm`).
- `php artisan optimize` + `sudo -n chown -R www-data:www-data storage/framework/views` + `reload php8.3-fpm`.

## Fase 2 (fuera de este plan)

Bloques 3 y 4 enriquecidos, gráficos ricos en Filament (serie mensual ingresos vs gastos, desgloses, tabla por cobrador), y el **saneamiento del ledger contable** (partir el pago en capital/interés + backfill de 2.272 filas) como proyecto propio.
