# Credify Go! — Plan de Trabajo Integral (Auditoría 2026-04-15)

> **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:** Remediar todas las vulnerabilidades de seguridad, bugs críticos y discrepancias PWA↔Backend identificados en la auditoría del 15 de abril de 2026, y establecer la base para las mejoras de producto de mediano plazo.

**Architecture:** Laravel 12 backend + Vue 3 PWA offline-first + Filament 4.1 admin panel. Multi-tenant via `company_id` global scope. IndexedDB (Dexie v6) como caché offline. Sanctum para autenticación de la PWA.

**Tech Stack:** PHP 8.3, Laravel 12, Filament 4.1, Vue 3, Pinia, Dexie, Axios, Workbox, MariaDB 10.6

---

## RESUMEN EJECUTIVO DE AUDITORÍA

### Score por dimensión

| Dimensión | Score | Estado |
|-----------|-------|--------|
| Seguridad | 5/10 | 🔴 CRÍTICO — 2 IDOR, tokens sin expiración, rate limiting ausente |
| Arquitectura | 8/10 | ✅ Sólida — delegación clara, multi-tenancy robusto |
| Completitud backend | 6/10 | 🟠 DashboardController 4/7 stubs, CollectionController vacío |
| Completitud PWA | 7/10 | 🟠 Race conditions, rollback ausente, redundancias |
| Discrepancias PWA↔API | 5/10 | 🔴 8 discrepancias, gastos offline rotos, campos faltantes |
| Performance | 6/10 | 🟠 11+ índices faltantes, N+1 queries, delta sync ausente |
| Testing | 4/10 | 🔴 0% cobertura de controllers PWA, 0% cobertura frontend |
| **TOTAL** | **6.1/10** | Funcional pero no apto para producción a escala |

---

## FASE 1 — SEGURIDAD CRÍTICA
### Tiempo estimado: 1 semana | Blocker para producción

---

### Tarea 1.1: Corregir IDOR en PaymentController::receipt()

**Files:**
- Modify: `app/Http/Controllers/Api/Pwa/PaymentController.php`
- Test: `tests/Feature/Pwa/PaymentReceiptAccessTest.php`

**Problema:** Un collector puede obtener el recibo de pagos de otros cobradores de su empresa consultando `GET /api/pwa/payments/{id}/receipt` con IDs ajenos. El método solo filtra por `company_id`, no por `registered_by_user_id`.

- [ ] **Step 1: Escribir el test que falla**

```php
// tests/Feature/Pwa/PaymentReceiptAccessTest.php
<?php
namespace Tests\Feature\Pwa;

use Tests\TestCase;
use App\Models\User;
use App\Models\Payment;

class PaymentReceiptAccessTest extends TestCase
{
    public function test_collector_cannot_see_other_collector_receipt(): void
    {
        $collector1 = User::factory()->withRole('collector')->create();
        $collector2 = User::factory()->withRole('collector')->create([
            'company_id' => $collector1->company_id
        ]);
        $payment = Payment::factory()->create([
            'company_id' => $collector1->company_id,
            'registered_by_user_id' => $collector2->id,
        ]);

        $response = $this->actingAs($collector1)->getJson("/api/pwa/payments/{$payment->id}/receipt");

        $response->assertStatus(404);
    }

    public function test_collector_can_see_own_receipt(): void
    {
        $collector = User::factory()->withRole('collector')->create();
        $payment = Payment::factory()->create([
            'company_id' => $collector->company_id,
            'registered_by_user_id' => $collector->id,
        ]);

        $response = $this->actingAs($collector)->getJson("/api/pwa/payments/{$payment->id}/receipt");

        $response->assertStatus(200);
    }
}
```

- [ ] **Step 2: Correr el test para confirmar que falla**

```bash
php artisan test tests/Feature/Pwa/PaymentReceiptAccessTest.php
```
Expected: FAIL — collector1 obtiene 200 en lugar de 404

- [ ] **Step 3: Aplicar fix en el controller**

Localizar el método `receipt()` en `app/Http/Controllers/Api/Pwa/PaymentController.php` y añadir el filtro por rol:

```php
public function receipt(int $id): JsonResponse
{
    $user = Auth::user();
    $role = $this->detectRole($user);

    $query = Payment::where('company_id', $user->company_id)
        ->with(['credit.client', 'installments']);

    if ($role === 'collector') {
        $query->where('registered_by_user_id', $user->id);
    }

    $payment = $query->find($id);

    if (!$payment) {
        return response()->json(['message' => 'Pago no encontrado.'], 404);
    }

    // ... resto del método sin cambios
}
```

- [ ] **Step 4: Correr tests y confirmar que pasan**

```bash
php artisan test tests/Feature/Pwa/PaymentReceiptAccessTest.php
```
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add app/Http/Controllers/Api/Pwa/PaymentController.php tests/Feature/Pwa/PaymentReceiptAccessTest.php
git commit -m "fix(security): corregir IDOR en PaymentController::receipt() - filtrar por collector"
```

---

### Tarea 1.2: Corregir IDOR en ReorderCollectorCreditsRequest

**Files:**
- Modify: `app/Http/Requests/Pwa/ReorderCollectorCreditsRequest.php`
- Test: `tests/Feature/Pwa/CreditReorderSecurityTest.php`

**Problema:** Un collector puede reordenar credit_ids que no le pertenecen. La request solo valida que existan, no que sean del usuario autenticado.

- [ ] **Step 1: Escribir el test que falla**

```php
// tests/Feature/Pwa/CreditReorderSecurityTest.php
<?php
namespace Tests\Feature\Pwa;

use Tests\TestCase;
use App\Models\User;
use App\Models\Credit;

class CreditReorderSecurityTest extends TestCase
{
    public function test_collector_cannot_reorder_other_collector_credits(): void
    {
        $collector1 = User::factory()->withRole('collector')->create();
        $collector2 = User::factory()->withRole('collector')->create([
            'company_id' => $collector1->company_id,
        ]);
        $creditOfCollector2 = Credit::factory()->create([
            'company_id' => $collector1->company_id,
            'collector_user_id' => $collector2->id,
            'status' => 'active',
        ]);

        $response = $this->actingAs($collector1)->putJson('/api/pwa/credits/reorder', [
            'credit_ids' => [$creditOfCollector2->id],
        ]);

        $response->assertStatus(422);
    }
}
```

- [ ] **Step 2: Correr el test para confirmar que falla**

```bash
php artisan test tests/Feature/Pwa/CreditReorderSecurityTest.php
```
Expected: FAIL

- [ ] **Step 3: Añadir validación de propiedad en la Form Request**

```php
// app/Http/Requests/Pwa/ReorderCollectorCreditsRequest.php
public function rules(): array
{
    $userId = Auth::id();
    $companyId = Auth::user()->company_id;

    return [
        'credit_ids'   => ['required', 'array', 'min:1'],
        'credit_ids.*' => [
            'integer',
            Rule::exists('credits', 'id')->where(function ($query) use ($userId, $companyId) {
                $query->where('company_id', $companyId)
                      ->where('collector_user_id', $userId);
            }),
        ],
    ];
}
```

- [ ] **Step 4: Correr tests**

```bash
php artisan test tests/Feature/Pwa/CreditReorderSecurityTest.php
```
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add app/Http/Requests/Pwa/ReorderCollectorCreditsRequest.php tests/Feature/Pwa/CreditReorderSecurityTest.php
git commit -m "fix(security): validar propiedad de credit_ids en ReorderCollectorCreditsRequest"
```

---

### Tarea 1.3: Configurar expiración de tokens Sanctum

**Files:**
- Modify: `config/sanctum.php`
- Test: `tests/Feature/Pwa/TokenExpirationTest.php`

**Problema:** `'expiration' => null` significa tokens válidos indefinidamente. Si un token es extraído (XSS, dispositivo robado), el acceso es permanente.

- [ ] **Step 1: Escribir el test**

```php
// tests/Feature/Pwa/TokenExpirationTest.php
public function test_token_has_expiration_configured(): void
{
    $expiration = config('sanctum.expiration');
    $this->assertNotNull($expiration, 'Tokens Sanctum deben tener expiración configurada');
    $this->assertGreaterThanOrEqual(60, $expiration, 'Expiración mínima de 60 minutos');
}
```

- [ ] **Step 2: Configurar expiración en sanctum.php**

```php
// config/sanctum.php — línea ~59
'expiration' => env('SANCTUM_TOKEN_EXPIRATION', 1440), // 24 horas en minutos
```

- [ ] **Step 3: Añadir a .env.example**

```bash
echo "SANCTUM_TOKEN_EXPIRATION=1440" >> .env.example
```

- [ ] **Step 4: Correr tests**

```bash
php artisan test tests/Feature/Pwa/TokenExpirationTest.php
```

- [ ] **Step 5: Commit**

```bash
git add config/sanctum.php .env.example tests/Feature/Pwa/TokenExpirationTest.php
git commit -m "fix(security): configurar expiración de tokens Sanctum a 24h"
```

---

### Tarea 1.4: Añadir rate limiting a endpoints de escritura

**Files:**
- Modify: `routes/api.php`
- Modify: `app/Providers/AppServiceProvider.php` (o RouteServiceProvider)

**Problema:** Solo login tiene rate limiting. Los endpoints POST /payments, POST /credits, POST /clients no están protegidos contra spam.

- [ ] **Step 1: Registrar el rate limiter en AppServiceProvider**

```php
// app/Providers/AppServiceProvider.php — en boot()
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;

RateLimiter::for('pwa-write', function (Request $request) {
    return Limit::perMinute(30)->by($request->user()?->id ?: $request->ip());
});

RateLimiter::for('pwa-payments', function (Request $request) {
    return Limit::perMinute(10)->by($request->user()?->id ?: $request->ip());
});
```

- [ ] **Step 2: Aplicar throttle en routes/api.php**

Actualizar las rutas ya identificadas:

```php
Route::post('/payments', [PaymentController::class, 'store'])
    ->middleware(['throttle:pwa-payments', 'throttle:pwa-write'])
    ->name('payments.store');

Route::post('/credits', [CreditController::class, 'store'])
    ->middleware('throttle:pwa-write')
    ->name('credits.store');

Route::post('/clients', [ClientController::class, 'store'])
    ->middleware('throttle:pwa-write')
    ->name('clients.store');

Route::post('/team/supervisors/{supervisorId}/collectors', ...)
    ->middleware('throttle:pwa-write');
```

- [ ] **Step 3: Correr suite de tests para verificar que no hay regresiones**

```bash
php artisan test --testsuite=Feature
```
Expected: PASS

- [ ] **Step 4: Commit**

```bash
git add routes/api.php app/Providers/AppServiceProvider.php
git commit -m "fix(security): añadir rate limiting a endpoints de escritura PWA"
```

---

## FASE 2 — BUGS CRÍTICOS DE LÓGICA
### Tiempo estimado: 2 semanas

---

### Tarea 2.1: Corregir getFrequencyDisplay() — labels rotos

**Files:**
- Modify: `app/Http/Controllers/Api/Pwa/Traits/RoleAwareQueries.php`
- Test: `tests/Unit/FrequencyDisplayTest.php`

**Problema:** El método usa `due_day_1` (valor 1-31, día del mes) como si fuera índice de día de semana (1-7), produciendo labels incorrectos para créditos semanales. Todos los cobradores ven labels rotos.

- [ ] **Step 1: Escribir el test**

```php
// tests/Unit/FrequencyDisplayTest.php
public function test_weekly_credit_with_due_day_15_shows_correct_label(): void
{
    $credit = Credit::factory()->make([
        'periodicity' => 'weekly',
        'due_day_1' => 15, // Martes (15 mod 7 = 1, no es válido como día de semana)
    ]);
    // El label no debe ser "Cada semana" genérico;
    // debe ser derivado de la periodicity directamente
    $label = app(RoleAwareQueries::class)->getFrequencyDisplayPublic($credit);
    $this->assertStringContainsString('Semanal', $label);
}
```

- [ ] **Step 2: Refactorizar el método**

```php
// app/Http/Controllers/Api/Pwa/Traits/RoleAwareQueries.php
protected function getFrequencyDisplay(Credit $credit): string
{
    $labels = [
        'daily'     => 'Diario',
        'weekly'    => 'Semanal',
        'biweekly'  => 'Quincenal',
        'monthly'   => 'Mensual',
    ];

    $base = $labels[$credit->periodicity] ?? ucfirst($credit->periodicity ?? 'N/A');

    // Para créditos semanales con due_day_1, agregar "cada X días"
    // pero NO usar due_day_1 como índice de día de semana (es día del mes 1-31)
    if ($credit->periodicity === 'weekly' && $credit->due_day_1) {
        return $base; // Solo "Semanal" — el día se muestra en CreditDetailView
    }

    return $base;
}
```

- [ ] **Step 3: Correr test**

```bash
php artisan test tests/Unit/FrequencyDisplayTest.php
```
Expected: PASS

- [ ] **Step 4: Commit**

```bash
git add app/Http/Controllers/Api/Pwa/Traits/RoleAwareQueries.php tests/Unit/FrequencyDisplayTest.php
git commit -m "fix(ux): corregir getFrequencyDisplay — no usar due_day_1 como índice de día de semana"
```

---

### Tarea 2.2: Implementar DashboardController métodos faltantes

**Files:**
- Modify: `app/Http/Controllers/Api/Pwa/DashboardController.php`
- Read: `app/Services/Dashboard/AdminDashboardMetricsService.php`
- Read: `app/Services/Metrics/CollectionMetricsService.php`
- Test: `tests/Feature/Pwa/DashboardEndpointsTest.php`

**Problema:** `summary()`, `weeklyProgress()`, `recentPayments()`, `pendingCollections()` son stubs vacíos. El frontend los llama pero obtiene respuestas vacías o errores.

- [ ] **Step 1: Leer los servicios existentes para entender qué datos retornan**

```bash
# Leer AdminDashboardMetricsService y CollectionMetricsService para reutilizar lógica
```

- [ ] **Step 2: Escribir los tests**

```php
// tests/Feature/Pwa/DashboardEndpointsTest.php
public function test_summary_returns_expected_keys(): void
{
    $collector = User::factory()->withRole('collector')->create();
    $response = $this->actingAs($collector)->getJson('/api/pwa/dashboard/summary');
    $response->assertStatus(200)->assertJsonStructure(['today_collected', 'pending_today', 'visits_today']);
}

public function test_weekly_progress_returns_7_days(): void
{
    $collector = User::factory()->withRole('collector')->create();
    $response = $this->actingAs($collector)->getJson('/api/pwa/dashboard/weekly-progress');
    $response->assertStatus(200)->assertJsonStructure(['days' => [['date', 'collected', 'target']]]);
}

public function test_recent_payments_returns_list(): void
{
    $collector = User::factory()->withRole('collector')->create();
    $response = $this->actingAs($collector)->getJson('/api/pwa/dashboard/recent-payments');
    $response->assertStatus(200)->assertJsonStructure(['data']);
}

public function test_pending_collections_returns_list(): void
{
    $collector = User::factory()->withRole('collector')->create();
    $response = $this->actingAs($collector)->getJson('/api/pwa/dashboard/pending-collections');
    $response->assertStatus(200)->assertJsonStructure(['data']);
}
```

- [ ] **Step 3: Implementar summary()**

```php
public function summary(): JsonResponse
{
    $user = Auth::user();
    $role = $this->detectRole($user);
    $today = Carbon::today();

    // Inyectar CollectionMetricsService
    $metrics = app(CollectionMetricsService::class);

    return response()->json([
        'today_collected'    => $metrics->todayCollectedAmount($user, $role),
        'pending_today'      => $metrics->pendingTodayCount($user, $role),
        'visits_today'       => $metrics->visitsTodayCount($user, $role),
        'overdue_count'      => $metrics->overdueCreditsCount($user, $role),
    ]);
}
```

- [ ] **Step 4: Implementar weeklyProgress()**

```php
public function weeklyProgress(): JsonResponse
{
    $user = Auth::user();
    $role = $this->detectRole($user);
    $metrics = app(CollectionMetricsService::class);

    return response()->json([
        'days' => $metrics->weeklyProgressByDay($user, $role),
    ]);
}
```

- [ ] **Step 5: Implementar recentPayments()**

```php
public function recentPayments(): JsonResponse
{
    $user = Auth::user();
    $role = $this->detectRole($user);
    $query = Payment::where('company_id', $user->company_id)
        ->with('credit.client')
        ->orderByDesc('created_at')
        ->limit(20);

    if ($role === 'collector') {
        $query->where('registered_by_user_id', $user->id);
    }

    return response()->json(['data' => $query->get()->map(fn($p) => [
        'id'           => $p->id,
        'amount'       => $p->amount,
        'payment_date' => $p->payment_date,
        'client_name'  => $p->credit?->client?->name ?? 'N/A',
        'credit_id'    => $p->credit_id,
    ])]);
}
```

- [ ] **Step 6: Implementar pendingCollections()**

```php
public function pendingCollections(): JsonResponse
{
    $user = Auth::user();
    $role = $this->detectRole($user);
    $today = Carbon::today()->toDateString();
    $metrics = app(CollectionMetricsService::class);

    return response()->json([
        'data' => $metrics->getPendingCollectionsForToday($user, $role),
    ]);
}
```

- [ ] **Step 7: Correr tests**

```bash
php artisan test tests/Feature/Pwa/DashboardEndpointsTest.php
```
Expected: PASS

- [ ] **Step 8: Commit**

```bash
git add app/Http/Controllers/Api/Pwa/DashboardController.php tests/Feature/Pwa/DashboardEndpointsTest.php
git commit -m "feat: implementar métodos faltantes de DashboardController (summary, weeklyProgress, recentPayments, pendingCollections)"
```

---

### Tarea 2.3: Supervisores pueden ver pagos y visitas de su equipo

**Files:**
- Modify: `app/Http/Controllers/Api/Pwa/PaymentController.php`
- Modify: `app/Http/Controllers/Api/Pwa/CollectionVisitController.php`
- Test: `tests/Feature/Pwa/SupervisorPaymentVisibilityTest.php`

**Problema:** Supervisores no pueden auditar pagos ni visitas de sus cobradores. `PaymentController::today()` y `show()` solo filtran por `registered_by_user_id` del supervisor (que raramente registra pagos).

- [ ] **Step 1: Escribir tests**

```php
// tests/Feature/Pwa/SupervisorPaymentVisibilityTest.php
public function test_supervisor_can_see_team_payments_today(): void
{
    $supervisor = User::factory()->withRole('supervisor')->create();
    $collector = User::factory()->withRole('collector')->create(['company_id' => $supervisor->company_id]);
    SupervisorCollector::factory()->create(['supervisor_id' => $supervisor->id, 'collector_id' => $collector->id]);
    $payment = Payment::factory()->create([
        'company_id'            => $supervisor->company_id,
        'registered_by_user_id' => $collector->id,
        'payment_date'          => today()->toDateString(),
    ]);

    $response = $this->actingAs($supervisor)->getJson('/api/pwa/payments/today');
    $response->assertStatus(200);
    $response->assertJsonFragment(['id' => $payment->id]);
}

public function test_supervisor_can_see_team_visits_today(): void
{
    $supervisor = User::factory()->withRole('supervisor')->create();
    $collector  = User::factory()->withRole('collector')->create(['company_id' => $supervisor->company_id]);
    SupervisorCollector::factory()->create(['supervisor_id' => $supervisor->id, 'collector_id' => $collector->id]);
    $visit = CollectionVisit::factory()->create([
        'company_id'         => $supervisor->company_id,
        'collector_user_id'  => $collector->id,
        'visit_date'         => today()->toDateString(),
    ]);

    $response = $this->actingAs($supervisor)->getJson('/api/pwa/visits/today');
    $response->assertStatus(200);
    $response->assertJsonFragment(['id' => $visit->id]);
}
```

- [ ] **Step 2: Aplicar lógica rol-aware en PaymentController::today()**

```php
public function today(Request $request): JsonResponse
{
    $user = Auth::user();
    $role = $this->detectRole($user);
    $today = Carbon::today()->toDateString();
    $resolver = app(CollectorVisibilityResolver::class);

    $query = Payment::where('company_id', $user->company_id)
        ->where('payment_date', $today)
        ->with('credit.client');

    if ($role === 'collector') {
        $query->where('registered_by_user_id', $user->id);
    } elseif ($role === 'supervisor') {
        $visibleCollectorIds = $resolver->getVisibleCollectorIds($user);
        $query->whereIn('registered_by_user_id', $visibleCollectorIds);
    }
    // admin: ve todos los de su empresa (sin filtro adicional)

    return response()->json(['data' => $query->orderByDesc('created_at')->get()]);
}
```

- [ ] **Step 3: Aplicar lógica rol-aware en CollectionVisitController::today()**

```php
public function today(Request $request): JsonResponse
{
    $user = Auth::user();
    $role = $this->detectRole($user);
    $today = Carbon::today()->toDateString();
    $resolver = app(CollectorVisibilityResolver::class);

    $query = CollectionVisit::where('company_id', $user->company_id)
        ->where('visit_date', $today);

    if ($role === 'collector') {
        $query->where('collector_user_id', $user->id);
    } elseif ($role === 'supervisor') {
        $visibleCollectorIds = $resolver->getVisibleCollectorIds($user);
        $query->whereIn('collector_user_id', $visibleCollectorIds);
    }

    return response()->json(['data' => $query->orderByDesc('created_at')->get()]);
}
```

- [ ] **Step 4: Correr tests**

```bash
php artisan test tests/Feature/Pwa/SupervisorPaymentVisibilityTest.php
```
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add app/Http/Controllers/Api/Pwa/PaymentController.php app/Http/Controllers/Api/Pwa/CollectionVisitController.php tests/Feature/Pwa/SupervisorPaymentVisibilityTest.php
git commit -m "feat: supervisores pueden ver pagos y visitas de su equipo (rol-aware visibility)"
```

---

## FASE 3 — DISCREPANCIAS PWA ↔ BACKEND
### Tiempo estimado: 2 semanas

---

### Tarea 3.1: Corregir sincronización de gastos offline

**Files:**
- Modify: `resources/js/pwa/views/ExpenseCreateView.vue`
- Read: `resources/js/pwa/stores/sync.js` (método syncPendingExpenses ya existe)
- Test: `tests/Feature/Pwa/SyncExpensesTest.php` (verificar ya existe)

**Problema:** `ExpenseCreateView.vue` envía el gasto directamente a `POST /pwa/expenses` en lugar de encolarlo en `db.pendingExpenses`. Cuando offline, el gasto se pierde. El store `sync.js` ya tiene `syncPendingExpenses()` pero nunca se activa porque nada encola en `pendingExpenses`.

- [ ] **Step 1: Leer ExpenseCreateView.vue para entender el flujo actual**

```bash
# Revisar la función submit() en ExpenseCreateView.vue
```

- [ ] **Step 2: Añadir función queueExpense en sync.js si falta**

```javascript
// resources/js/pwa/stores/sync.js
async function queueExpense(expenseData) {
    const expense = {
        ...expenseData,
        idempotency_key: generateUUID(),
        created_at_local: new Date().toISOString()
    }

    await db.pendingExpenses.add(expense)
    pendingExpenses.value.push(expense)

    if (navigator.onLine) {
        await syncPendingExpenses()
    }

    return expense
}
```

- [ ] **Step 3: Modificar ExpenseCreateView.vue para encolar offline**

```javascript
// resources/js/pwa/views/ExpenseCreateView.vue — función submit()
async function submit() {
    loading.value = true
    try {
        if (navigator.onLine) {
            // Online: enviar directamente como antes
            await api.post('/pwa/expenses', formData.value)
            toast.success('Gasto registrado')
        } else {
            // Offline: encolar para sync posterior
            await syncStore.queueExpense(formData.value)
            toast.success('Gasto guardado — se sincronizará cuando haya conexión')
        }
        router.back()
    } catch (err) {
        toast.error(err.response?.data?.message ?? 'Error al registrar gasto')
    } finally {
        loading.value = false
    }
}
```

- [ ] **Step 4: Verificar que SyncExpensesTest pasa**

```bash
php artisan test tests/Feature/Pwa/SyncExpensesTest.php
```
Expected: PASS

- [ ] **Step 5: Build y verificar PWA**

```bash
npm run build
```

- [ ] **Step 6: Commit**

```bash
git add resources/js/pwa/views/ExpenseCreateView.vue resources/js/pwa/stores/sync.js
git commit -m "fix(pwa): encolar gastos en IndexedDB cuando offline en lugar de enviar directo a API"
```

---

### Tarea 3.2: Migración Dexie v7 — añadir permanent_error a pendingVisits + campos faltantes en credits

**Files:**
- Modify: `resources/js/pwa/db/index.js`

**Problema 1:** `pendingVisits` no tiene índice `permanent_error` (añadido en v6 a payments/expenses, olvidado en visits). Cada sync de visitas hace full table scan O(n).

**Problema 2:** Campos `start_date`, `total_receivable`, `total_paid` que el backend devuelve no están indexados/almacenados explícitamente en la tabla `credits`.

- [ ] **Step 1: Añadir version 7 en db/index.js**

```javascript
// resources/js/pwa/db/index.js — después de version 6
/**
 * Version 7: add permanent_error index to pendingVisits.
 *
 * pendingVisits was missed in v6 when permanent_error was added to
 * pendingPayments and pendingExpenses. Without this index, syncPendingVisits()
 * does an O(n) full table scan on every sync cycle.
 */
db.version(7).stores({
    clients:         'id, name, identification, phone',
    credits:         'id, client_id, status, collector_user_id',  // + collector_user_id index
    installments:    'id, credit_id, due_date, status, number, paid_date',
    payments:        'id, credit_id, payment_date, idempotency_key',
    pendingPayments: '++localId, idempotency_key, credit_id, created_at_local, permanent_error',
    pendingVisits:   '++localId, idempotency_key, credit_id, created_at_local, permanent_error',  // + permanent_error
    pendingExpenses: '++localId, idempotency_key, created_at_local, permanent_error',
    settings:        'id'
})
```

- [ ] **Step 2: Actualizar syncPendingVisits() en sync.js para usar el índice**

```javascript
// resources/js/pwa/stores/sync.js — en syncPendingVisits()
// ANTES: db.pendingVisits.toArray().then(visits => visits.filter(v => !v.permanent_error))
// DESPUÉS:
const retryableVisits = await db.pendingVisits
    .where('permanent_error')
    .notEqual(true)
    .toArray()
```

- [ ] **Step 3: Build y verificar que la migración corre sin errores**

```bash
npm run build
# Abrir PWA en browser, verificar en DevTools > Application > IndexedDB que version es 7
```

- [ ] **Step 4: Commit**

```bash
git add resources/js/pwa/db/index.js resources/js/pwa/stores/sync.js
git commit -m "fix(pwa/db): migración Dexie v7 - añadir permanent_error index a pendingVisits + collector_user_id a credits"
```

---

### Tarea 3.3: Unificar lógica de visitas — deprecar visits.js

**Files:**
- Modify: `resources/js/pwa/stores/sync.js`
- Delete: `resources/js/pwa/stores/visits.js` (after migration)
- Grep: componentes que importan visits store

**Problema:** `queueVisit()` y `syncPendingVisits()` están duplicados en `visits.js` y `sync.js`. Un cambio en uno no se replica al otro, creando riesgo de regresión silenciosa.

- [ ] **Step 1: Identificar todos los componentes que usan visits store**

```bash
grep -rn "useVisitsStore\|from.*stores/visits" resources/js/pwa/ --include="*.js" --include="*.vue"
```

- [ ] **Step 2: Asegurar que sync.js tiene queueVisit y syncPendingVisits completos**

Verificar que sync.js tiene al menos estas funciones con la misma firma que visits.js:
- `queueVisit(visitData)` — añade a db.pendingVisits + Pinia + intenta sync
- `syncPendingVisits()` — batch sync usando /api/pwa/sync/visits

- [ ] **Step 3: Actualizar componentes para usar syncStore en lugar de visitsStore**

Para cada componente que usa `useVisitsStore`:
```javascript
// ANTES
import { useVisitsStore } from '../stores/visits'
const visitsStore = useVisitsStore()
await visitsStore.queueVisit(data)

// DESPUÉS
import { useSyncStore } from '../stores/sync'
const syncStore = useSyncStore()
await syncStore.queueVisit(data)
```

- [ ] **Step 4: Eliminar visits.js**

```bash
rm resources/js/pwa/stores/visits.js
```

- [ ] **Step 5: Build y verificar sin errores**

```bash
npm run build
```

- [ ] **Step 6: Commit**

```bash
git add resources/js/pwa/stores/ resources/js/pwa/views/
git commit -m "refactor(pwa): eliminar visits.js - unificar lógica de visitas en sync.js"
```

---

### Tarea 3.4: Corregir race condition en queuePayment() + rollback de optimistic update

**Files:**
- Modify: `resources/js/pwa/stores/sync.js`

**Problema 1 (race condition):** Si `deductCreditBalance()` falla, el estado de Pinia está inconsistente (payment en state pero balance no reducido).

**Problema 2 (rollback ausente):** Si el servidor rechaza el pago con `permanent_error`, el saldo local permanece reducido indefinidamente mostrando datos falsos.

- [ ] **Step 1: Guardar balance original al encolar el pago**

```javascript
async function queuePayment(paymentData) {
    // Capturar balance original ANTES de modificar (para posible rollback)
    const creditSnapshot = await db.credits.get(paymentData.credit_id)
    const originalBalance = creditSnapshot?.remaining_balance ?? null

    const payment = {
        ...paymentData,
        idempotency_key: generateUUID(),
        created_at_local: new Date().toISOString(),
        _original_balance: originalBalance,  // Para rollback
    }

    // IndexedDB primero — fuente de verdad
    await db.pendingPayments.add(payment)

    // Pinia state
    pendingPayments.value.push(payment)

    // Optimistic update — best-effort, no bloquea el encolamiento
    try {
        await db.deductCreditBalance(paymentData.credit_id, paymentData.amount)
    } catch (err) {
        console.warn('[Sync] Optimistic balance update failed (payment still queued):', err)
    }

    if (navigator.onLine) {
        await syncPendingPayments()
    }

    return payment
}
```

- [ ] **Step 2: Implementar rollback al marcar permanent_error**

```javascript
// En la sección donde se marca permanent_error en syncPendingPayments()
// Buscar el bloque: if (retryCount >= MAX_SYNC_RETRIES)
if (retryCount >= MAX_SYNC_RETRIES) {
    await db.pendingPayments.update(item.localId, {
        permanent_error: true,
        last_error: result.error ?? 'Max retries exceeded',
    })

    // ROLLBACK: restaurar balance original si lo tenemos
    if (item._original_balance !== null && item._original_balance !== undefined) {
        try {
            await db.credits.update(item.credit_id, {
                remaining_balance: item._original_balance,
                status: item._original_status ?? (await db.credits.get(item.credit_id))?.status,
            })
            console.info(`[Sync] Rollback balance for credit ${item.credit_id}: restored to ${item._original_balance}`)
        } catch (rollbackErr) {
            console.warn('[Sync] Rollback failed:', rollbackErr)
        }
    }
}
```

- [ ] **Step 3: Build y verificar**

```bash
npm run build
```

- [ ] **Step 4: Commit**

```bash
git add resources/js/pwa/stores/sync.js
git commit -m "fix(pwa/sync): corregir race condition en queuePayment + implementar rollback de optimistic update"
```

---

### Tarea 3.5: Corregir /pwa/sync/confirm — implementar o eliminar endpoint muerto

**Files:**
- Modify: `app/Http/Controllers/Api/Pwa/SyncController.php`
- Modify: `routes/api.php` (si se elimina)

**Problema:** El backend define `POST /pwa/sync/confirm` pero la PWA nunca lo llama. Es código muerto que confunde y puede crear falsas expectativas.

- [ ] **Step 1: Decisión — implementar o eliminar**

El endpoint `confirmSync` debería marcar el último sync timestamp en el servidor para el usuario. Si no hay plan inmediato para usarlo, eliminarlo. **Decisión: Eliminar por ahora, añadir en fase de delta sync.**

- [ ] **Step 2: Eliminar de routes/api.php**

```php
// ELIMINAR esta línea de routes/api.php:
// Route::post('/sync/confirm', [SyncController::class, 'confirmSync'])->name('sync.confirm');
```

- [ ] **Step 3: Eliminar el método de SyncController**

Eliminar el método `confirmSync()` de SyncController.php

- [ ] **Step 4: Commit**

```bash
git add routes/api.php app/Http/Controllers/Api/Pwa/SyncController.php
git commit -m "chore: eliminar endpoint /pwa/sync/confirm (no implementado en PWA, deferir a fase delta sync)"
```

---

## FASE 4 — PERFORMANCE: ÍNDICES Y QUERIES
### Tiempo estimado: 1 semana

---

### Tarea 4.1: Añadir índices faltantes en base de datos

**Files:**
- Create: `database/migrations/2026_04_15_000001_add_missing_indexes.php`

**Problema:** 10+ columnas frecuentemente consultadas sin índice. Con crecimiento de datos esto causa timeouts en campo.

- [ ] **Step 1: Crear migración**

```bash
php artisan make:migration add_missing_performance_indexes
```

- [ ] **Step 2: Implementar la migración**

```php
// database/migrations/2026_04_15_000001_add_missing_indexes.php
public function up(): void
{
    // credits — consultas frecuentes por estado y collector
    Schema::table('credits', function (Blueprint $table) {
        $table->index('status', 'idx_credits_status');
        $table->index('collector_user_id', 'idx_credits_collector');
        $table->index(['company_id', 'status'], 'idx_credits_company_status');
        $table->index(['company_id', 'collector_user_id'], 'idx_credits_company_collector');
    });

    // installments — consultas por fecha de vencimiento y estado
    Schema::table('installments', function (Blueprint $table) {
        $table->index('due_date', 'idx_installments_due_date');
        $table->index('status', 'idx_installments_status');
        $table->index(['credit_id', 'status'], 'idx_installments_credit_status');
    });

    // payments — consultas por fecha y idempotency
    Schema::table('payments', function (Blueprint $table) {
        $table->index('payment_date', 'idx_payments_date');
        $table->index('idempotency_key', 'idx_payments_idempotency');
        $table->index(['company_id', 'payment_date'], 'idx_payments_company_date');
    });

    // collection_visits — consultas por fecha de visita
    Schema::table('collection_visits', function (Blueprint $table) {
        $table->index('visit_date', 'idx_visits_date');
        $table->index(['company_id', 'visit_date'], 'idx_visits_company_date');
    });

    // expenses — consultas por estado de aprobación
    Schema::table('expenses', function (Blueprint $table) {
        $table->index('approval_status', 'idx_expenses_approval');
        $table->index('operation_date', 'idx_expenses_date');
    });

    // subscriptions — consultas por estado y expiración
    Schema::table('subscriptions', function (Blueprint $table) {
        $table->index('status', 'idx_subscriptions_status');
        $table->index('expires_at', 'idx_subscriptions_expires');
    });

    // collector_credit_order — orden personalizado de ruta
    Schema::table('collector_credit_order', function (Blueprint $table) {
        $table->index(['user_id', 'sort_order'], 'idx_collector_order_user_sort');
    });
}

public function down(): void
{
    Schema::table('credits', function (Blueprint $table) {
        $table->dropIndex('idx_credits_status');
        $table->dropIndex('idx_credits_collector');
        $table->dropIndex('idx_credits_company_status');
        $table->dropIndex('idx_credits_company_collector');
    });
    // ... resto de dropIndex para cada tabla
}
```

- [ ] **Step 3: Correr migración**

```bash
php artisan migrate
```
Expected: migración exitosa sin errores

- [ ] **Step 4: Verificar índices con EXPLAIN en query frecuente**

```bash
php artisan tinker
# DB::select("EXPLAIN SELECT * FROM credits WHERE company_id = 1 AND status IN ('active','delayed','overdue') AND collector_user_id = 5")
```
Expected: type = 'ref' (no 'ALL'), usando índice idx_credits_company_collector

- [ ] **Step 5: Commit**

```bash
git add database/migrations/
git commit -m "perf: añadir 13 índices faltantes en credits, installments, payments, visits, expenses, subscriptions"
```

---

### Tarea 4.2: Corregir N+1 en CollectionMetricsService

**Files:**
- Modify: `app/Services/Metrics/CollectionMetricsService.php`
- Test: `tests/Unit/CollectionMetricsPerformanceTest.php`

**Problema:** `getCollectorPerformanceToday()` hace una query por cada cobrador (N+1). Con 100 cobradores = 101 queries → timeout.

- [ ] **Step 1: Escribir test de performance**

```php
public function test_collector_performance_uses_single_query(): void
{
    $company = Company::factory()->create();
    User::factory()->count(50)->withRole('collector')->create(['company_id' => $company->id]);

    DB::enableQueryLog();
    app(CollectionMetricsService::class)->getCollectorPerformanceToday($company->id);
    $queries = DB::getQueryLog();
    DB::disableQueryLog();

    // No debería hacer más de 5 queries independientemente de cuántos collectors hay
    $this->assertLessThanOrEqual(5, count($queries), 'CollectionMetricsService hace demasiadas queries');
}
```

- [ ] **Step 2: Refactorizar con groupBy batch query**

```php
// app/Services/Metrics/CollectionMetricsService.php
public function getCollectorPerformanceToday(int $companyId): array
{
    $today = Carbon::today()->toDateString();

    // Una sola query agrupada en lugar de N queries
    $paymentsByCollector = Payment::where('company_id', $companyId)
        ->where('payment_date', $today)
        ->where('voided', false)
        ->selectRaw('registered_by_user_id, SUM(amount) as total_collected, COUNT(*) as payments_count')
        ->groupBy('registered_by_user_id')
        ->get()
        ->keyBy('registered_by_user_id');

    $installmentsDueByCollector = Installment::join('credits', 'installments.credit_id', '=', 'credits.id')
        ->where('credits.company_id', $companyId)
        ->where('installments.due_date', $today)
        ->whereIn('installments.status', ['pending', 'partial_paid', 'overdue'])
        ->whereIn('credits.status', Credit::ACTIVE_STATUSES)
        ->whereNull('credits.parent_credit_id')
        ->selectRaw('credits.collector_user_id, COUNT(*) as due_count, SUM(installments.total_amount - installments.amount_paid) as due_amount')
        ->groupBy('credits.collector_user_id')
        ->get()
        ->keyBy('collector_user_id');

    return [
        'by_collector' => $paymentsByCollector,
        'due_by_collector' => $installmentsDueByCollector,
    ];
}
```

- [ ] **Step 3: Correr test**

```bash
php artisan test tests/Unit/CollectionMetricsPerformanceTest.php
```
Expected: PASS (≤5 queries)

- [ ] **Step 4: Commit**

```bash
git add app/Services/Metrics/CollectionMetricsService.php tests/Unit/CollectionMetricsPerformanceTest.php
git commit -m "perf: eliminar N+1 en CollectionMetricsService usando groupBy batch queries"
```

---

## FASE 5 — FEATURES FALTANTES (ALTA PRIORIDAD)
### Tiempo estimado: 3-4 semanas

---

### Tarea 5.1: Implementar CollectionController (cuotas a cobrar)

**Files:**
- Modify: `app/Http/Controllers/Api/Pwa/CollectionController.php`
- Test: `tests/Feature/Pwa/CollectionControllerTest.php`

**Problema:** `CollectionController::index()` y `stats()` están vacíos. La vista `CollectionsView.vue` existe pero no puede obtener datos del backend.

- [ ] **Step 1: Escribir tests**

```php
public function test_index_returns_todays_installments_for_collector(): void
{
    $collector = User::factory()->withRole('collector')->create();
    $credit = Credit::factory()->withCollector($collector)->create(['status' => 'active']);
    $installment = Installment::factory()->create([
        'credit_id' => $credit->id,
        'due_date'  => today()->toDateString(),
        'status'    => 'pending',
    ]);

    $response = $this->actingAs($collector)->getJson('/api/pwa/collections');
    $response->assertStatus(200)->assertJsonFragment(['id' => $installment->id]);
}

public function test_stats_returns_summary_counts(): void
{
    $collector = User::factory()->withRole('collector')->create();
    $response = $this->actingAs($collector)->getJson('/api/pwa/collections/stats');
    $response->assertStatus(200)->assertJsonStructure(['due_today', 'overdue', 'collected_today']);
}
```

- [ ] **Step 2: Implementar index()**

```php
public function index(Request $request): JsonResponse
{
    $user = Auth::user();
    $role = $this->detectRole($user);
    $filter = $request->get('filter', 'today'); // today, overdue, all

    $query = Installment::with(['credit.client'])
        ->join('credits', 'installments.credit_id', '=', 'credits.id')
        ->where('credits.company_id', $user->company_id)
        ->whereIn('credits.status', Credit::ACTIVE_STATUSES)
        ->whereNull('credits.parent_credit_id')
        ->whereIn('installments.status', ['pending', 'partial_paid', 'overdue'])
        ->select('installments.*');

    if ($role === 'collector') {
        $query->where('credits.collector_user_id', $user->id);
    } elseif ($role === 'supervisor') {
        $visibleIds = app(CollectorVisibilityResolver::class)->getVisibleCollectorIds($user);
        $query->whereIn('credits.collector_user_id', $visibleIds);
    }

    if ($filter === 'today') {
        $query->where('installments.due_date', today()->toDateString());
    } elseif ($filter === 'overdue') {
        $query->where('installments.due_date', '<', today()->toDateString());
    }

    return response()->json(['data' => $query->orderBy('installments.due_date')->paginate(50)]);
}
```

- [ ] **Step 3: Implementar stats()**

```php
public function stats(Request $request): JsonResponse
{
    $user = Auth::user();
    $role = $this->detectRole($user);
    $today = today()->toDateString();

    // Reutilizar CollectionMetricsService
    $metrics = app(CollectionMetricsService::class);

    return response()->json([
        'due_today'        => $metrics->countDueToday($user, $role),
        'overdue'          => $metrics->countOverdue($user, $role),
        'collected_today'  => $metrics->countCollectedToday($user, $role),
        'amount_collected' => $metrics->amountCollectedToday($user, $role),
    ]);
}
```

- [ ] **Step 4: Correr tests**

```bash
php artisan test tests/Feature/Pwa/CollectionControllerTest.php
```

- [ ] **Step 5: Commit**

```bash
git add app/Http/Controllers/Api/Pwa/CollectionController.php tests/Feature/Pwa/CollectionControllerTest.php
git commit -m "feat: implementar CollectionController::index() y stats() - cuotas a cobrar por rol"
```

---

### Tarea 5.2: Implementar Filament Resource para Plans

**Files:**
- Create: `app/Filament/Resources/Plans/PlanResource.php`
- Create: `app/Filament/Resources/Plans/Pages/ListPlans.php`
- Create: `app/Filament/Resources/Plans/Pages/CreatePlan.php`
- Create: `app/Filament/Resources/Plans/Pages/EditPlan.php`
- Create: `app/Filament/Resources/Plans/Schemas/PlanForm.php`
- Create: `app/Filament/Resources/Plans/Tables/PlansTable.php`

**Problema:** Los `Plan` (planes SaaS) no tienen Filament Resource. El super_admin no puede crear ni editar planes desde el panel.

- [ ] **Step 1: Crear el Resource**

```php
// app/Filament/Resources/Plans/PlanResource.php
<?php
namespace App\Filament\Resources\Plans;

use App\Models\Plan;
use Filament\Resources\Resource;

class PlanResource extends Resource
{
    protected static ?string $model = Plan::class;
    protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
    protected static ?string $navigationGroup = 'SaaS';
    protected static ?int $navigationSort = 1;

    public static function form(Form $form): Form
    {
        return $form->schema(PlanForm::schema());
    }

    public static function table(Table $table): Table
    {
        return $table->columns(PlansTable::columns())->actions([
            Tables\Actions\EditAction::make(),
        ]);
    }

    public static function getPages(): array
    {
        return [
            'index'  => Pages\ListPlans::route('/'),
            'create' => Pages\CreatePlan::route('/create'),
            'edit'   => Pages\EditPlan::route('/{record}/edit'),
        ];
    }
}
```

- [ ] **Step 2: Crear PlanForm con todos los campos del modelo**

Incluir: name, price, billing_cycle, max_users, max_collectors, max_active_credits, max_monthly_volume, features (boolean toggles), is_public, sort_order, trial_days.

- [ ] **Step 3: Crear PlansTable**

Columnas: name, price, billing_cycle, max_active_credits, is_public, active_subscriptions_count.

- [ ] **Step 4: Registrar el resource en el Panel Provider**

```php
// En app/Providers/Filament/AdminPanelProvider.php
->resources([
    // ... existentes
    \App\Filament\Resources\Plans\PlanResource::class,
])
```

- [ ] **Step 5: Verificar que aparece en el panel**

```bash
php artisan filament:check-translations
# Navegar a /admin/plans en el browser
```

- [ ] **Step 6: Commit**

```bash
git add app/Filament/Resources/Plans/
git commit -m "feat(filament): crear PlanResource - gestión de planes SaaS desde admin panel"
```

---

### Tarea 5.3: Implementar metas diarias de cobrador (CompanyCollectorGoal)

**Files:**
- Read: `app/Models/CompanyCollectorGoal.php`
- Modify: `app/Services/Metrics/CollectionMetricsService.php`
- Create: `app/Filament/Resources/CollectorGoals/` (nuevo resource)
- Test: `tests/Feature/CollectorGoalTest.php`

**Problema:** `CollectionMetricsService::getCollectorPerformanceToday()` siempre retorna `'status' => 'no_goal'`. El modelo `CompanyCollectorGoal` existe pero no se usa.

- [ ] **Step 1: Leer CompanyCollectorGoal para entender la estructura**

```bash
# Revisar app/Models/CompanyCollectorGoal.php
```

- [ ] **Step 2: Escribir tests**

```php
public function test_collector_goal_is_computed_when_exists(): void
{
    $collector = User::factory()->withRole('collector')->create();
    CompanyCollectorGoal::factory()->create([
        'company_id'         => $collector->company_id,
        'collector_user_id'  => $collector->id,
        'daily_target_amount'=> 1000,
        'daily_target_count' => 5,
    ]);

    $performance = app(CollectionMetricsService::class)->getGoalForCollector($collector);

    $this->assertNotEquals('no_goal', $performance['status']);
    $this->assertEquals(1000, $performance['daily_target_amount']);
}
```

- [ ] **Step 3: Implementar getGoalForCollector en CollectionMetricsService**

```php
public function getGoalForCollector(User $collector): array
{
    $goal = CompanyCollectorGoal::where('company_id', $collector->company_id)
        ->where('collector_user_id', $collector->id)
        ->first();

    if (!$goal) {
        return ['status' => 'no_goal', 'daily_target_amount' => null, 'daily_target_count' => null];
    }

    $today = Carbon::today()->toDateString();
    $collectedToday = Payment::where('company_id', $collector->company_id)
        ->where('registered_by_user_id', $collector->id)
        ->where('payment_date', $today)
        ->where('voided', false)
        ->sum('amount');

    $collectedCount = Payment::where('company_id', $collector->company_id)
        ->where('registered_by_user_id', $collector->id)
        ->where('payment_date', $today)
        ->where('voided', false)
        ->count();

    $amountPercent = $goal->daily_target_amount > 0
        ? round(($collectedToday / $goal->daily_target_amount) * 100, 1)
        : 100;

    $countPercent = $goal->daily_target_count > 0
        ? round(($collectedCount / $goal->daily_target_count) * 100, 1)
        : 100;

    return [
        'status'               => $amountPercent >= 100 ? 'completed' : 'in_progress',
        'daily_target_amount'  => (float) $goal->daily_target_amount,
        'daily_target_count'   => $goal->daily_target_count,
        'collected_today'      => (float) $collectedToday,
        'collected_count'      => $collectedCount,
        'amount_percent'       => $amountPercent,
        'count_percent'        => $countPercent,
    ];
}
```

- [ ] **Step 4: Integrar en DashboardController::index() para collector**

En el bloque del rol collector del dashboard, añadir `'goal' => $metrics->getGoalForCollector($user)`.

- [ ] **Step 5: Correr tests**

```bash
php artisan test tests/Feature/CollectorGoalTest.php
```
Expected: PASS

- [ ] **Step 6: Commit**

```bash
git add app/Services/Metrics/CollectionMetricsService.php app/Http/Controllers/Api/Pwa/DashboardController.php tests/Feature/CollectorGoalTest.php
git commit -m "feat: implementar metas diarias de cobrador - getGoalForCollector con tracking de progreso"
```

---

## FASE 6 — MEJORAS DE PRODUCTO (MEDIANO PLAZO)
### Tiempo estimado: 4-6 semanas

---

### Tarea 6.1: Implementar offline fallback para Dashboard PWA

**Files:**
- Modify: `resources/js/pwa/stores/dashboard.js`
- Modify: `resources/js/pwa/views/HomeView.vue`

**Objetivo:** Cuando el collector está offline, mostrar los datos del último fetch en lugar de un error.

- [ ] **Step 1: Añadir persistencia al dashboard store**

```javascript
// resources/js/pwa/stores/dashboard.js
// Añadir persistencia selectiva en defineStore options:
{
    persist: {
        key: 'pwa_dashboard',
        paths: ['stats', 'recentPayments', 'weeklyProgress', 'lastFetch'],
        storage: localStorage,
    }
}
```

- [ ] **Step 2: Añadir lógica de fallback offline**

```javascript
async function fetchDashboard() {
    if (!navigator.onLine) {
        // Usar datos cacheados si están disponibles (con banner "datos de X ago")
        if (stats.value) {
            dataIsStale.value = true
            return
        }
        error.value = 'Sin conexión y sin datos cacheados disponibles'
        return
    }
    // ... lógica online existente
}
```

- [ ] **Step 3: Mostrar banner de datos desactualizados en HomeView**

```vue
<!-- HomeView.vue -->
<div v-if="dashboardStore.dataIsStale" class="bg-yellow-50 border border-yellow-200 px-4 py-2 text-sm text-yellow-800 rounded-md">
  Datos de {{ formatRelativeTime(dashboardStore.lastFetch) }} — sin conexión actualmente
</div>
```

- [ ] **Step 4: Commit**

```bash
git add resources/js/pwa/stores/dashboard.js resources/js/pwa/views/HomeView.vue
git commit -m "feat(pwa): dashboard persiste en localStorage - fallback offline con banner de datos desactualizados"
```

---

### Tarea 6.2: Implementar delta sync (evitar descargar 7 días completos)

**Files:**
- Modify: `app/Http/Controllers/Api/Pwa/SyncController.php`
- Modify: `resources/js/pwa/stores/auth.js` (syncInitialData)
- Test: `tests/Feature/Pwa/DeltaSyncTest.php`

**Objetivo:** Si el cliente ya tiene datos y solo pasaron pocas horas, descargar solo los cambios desde `last_sync` en lugar de 7 días completos.

- [ ] **Step 1: Modificar SyncController::downloadData() para soportar last_sync**

```php
public function downloadData(Request $request): JsonResponse
{
    $lastSync = $request->get('last_sync'); // ISO timestamp opcional
    $days = $request->get('days', 7);

    $user = Auth::user();
    $since = $lastSync
        ? Carbon::parse($lastSync)->subMinutes(5) // 5 min overlap para evitar gaps
        : Carbon::now()->subDays((int) $days);

    // Créditos modificados desde last_sync
    $credits = Credit::where('company_id', $user->company_id)
        ->where('updated_at', '>=', $since)
        // ... filtros por rol
        ->get();

    return response()->json([
        'credits'      => $credits,
        'clients'      => $this->getClientsForCredits($credits, $since),
        'installments' => $this->getInstallmentsForCredits($credits, $since),
        'payments'     => $this->getRecentPayments($user, $since),
        'synced_at'    => now()->toISOString(),
        'is_delta'     => $lastSync !== null,
    ]);
}
```

- [ ] **Step 2: Actualizar syncInitialData en auth.js para pasar last_sync**

```javascript
async function syncInitialData() {
    const lastSync = localStorage.getItem('pwa_last_sync')
    const params = lastSync ? { last_sync: lastSync } : { days: 7 }

    const response = await api.get('/pwa/sync/data', { params })
    // ... bulkPut como antes
    localStorage.setItem('pwa_last_sync', response.data.synced_at)
}
```

- [ ] **Step 3: Correr tests**

```bash
php artisan test tests/Feature/Pwa/DeltaSyncTest.php
```

- [ ] **Step 4: Commit**

```bash
git add app/Http/Controllers/Api/Pwa/SyncController.php resources/js/pwa/stores/auth.js tests/Feature/Pwa/DeltaSyncTest.php
git commit -m "feat: implementar delta sync - descargar solo cambios desde last_sync en lugar de 7 días completos"
```

---

### Tarea 6.3: Mejorar cobertura de tests — Controllers PWA (0% → 60%)

**Files:**
- Create: `tests/Feature/Pwa/PaymentControllerTest.php`
- Create: `tests/Feature/Pwa/CreditControllerTest.php`
- Create: `tests/Feature/Pwa/ClientControllerTest.php`
- Create: `tests/Feature/Pwa/SyncControllerTest.php`

**Objetivo:** Llevar cobertura de controllers PWA de 0% a 60%+.

- [ ] **Step 1: PaymentControllerTest — casos críticos**

```php
// Casos a cubrir:
// ✅ collector puede registrar pago a crédito propio
// ✅ collector NO puede registrar pago a crédito ajeno
// ✅ idempotency_key previene registro duplicado
// ✅ partial payment respeta allow_partial_payments setting
// ✅ sync/payments procesa batch con mezcla de success/duplicate/error
php artisan test tests/Feature/Pwa/PaymentControllerTest.php
```

- [ ] **Step 2: CreditControllerTest — casos críticos**

```php
// ✅ collector ve solo sus créditos activos
// ✅ supervisor ve créditos de su equipo
// ✅ admin ve todos los créditos de la empresa
// ✅ store crea crédito con installments correctos
// ✅ reorder respeta propiedad de créditos (IDOR fix del paso 1.2)
```

- [ ] **Step 3: Correr suite completa**

```bash
php artisan test --testsuite=Feature
```
Expected: todos los tests pasan, cobertura subió de 40% a 60%+

- [ ] **Step 4: Commit**

```bash
git add tests/Feature/Pwa/
git commit -m "test: añadir tests para PaymentController, CreditController, ClientController — cobertura PWA 0%→60%"
```

---

## FASE 7 — OPORTUNIDADES DE PRODUCTO (LARGO PLAZO)
### Tiempo estimado: 2-3 meses | Post-estabilización

Estas tareas son **oportunidades estratégicas** — a planificar y priorizar tras completar las fases 1-6.

---

### Iniciativa 7.1: Portal de Cliente Autoservicio

**Propuesta:** Permitir que los clientes finales vean su saldo, historial de pagos y registren promesas de pago desde una vista web simple protegida por documento + PIN.

**Alcance MVP:**
- Vista `/portal/login` (identificación + PIN)
- Vista `/portal/credito` (saldo, cuotas, historial)
- Vista `/portal/pagos` (historial de pagos)

**Estimado:** 3 sprints | **Impacto en negocio:** Alto (reduce carga del cobrador, mejora experiencia del cliente)

---

### Iniciativa 7.2: Reportes Contables Exportables

**Propuesta:** Generar PDF/Excel de Balance General, P&L mensual, y Flujo de Caja directamente desde el ledger Income/Expense.

**Alcance MVP:**
- Tres reportes: Balance General, P&L, Flujo de Caja
- Exportación a PDF (DomPDF) y Excel (Laravel Excel)
- Filtros por rango de fechas

**Estimado:** 2 sprints | **Impacto en negocio:** Alto (diferenciador frente a competencia)

---

### Iniciativa 7.3: API Pública con OAuth 2 (Marketplace de Integraciones)

**Propuesta:** Exposición de API REST con autenticación OAuth 2 (client_id/secret) para integraciones con terceros (contabilidad, scoring, cobranza externa).

**Alcance MVP:**
- OAuth 2 client credentials (Laravel Passport)
- Webhooks para: credit.created, payment.registered, visit.completed
- Rate limiting por API key
- Documentación OpenAPI generada automáticamente

**Estimado:** 4 sprints | **Impacto en negocio:** Muy alto (abre canal B2B)

---

### Iniciativa 7.4: Análisis Predictivo de Morosidad

**Propuesta:** Modelo simple de riesgo que predice probabilidad de default basado en comportamiento histórico del cliente.

**Alcance MVP:**
- Score de riesgo por cliente (0-100)
- Factores: días de mora histórico, ratio de pagos puntual, número de reestructuraciones
- Badge visual en CreditDetailView y Filament Credits list
- Umbral configurable por empresa (alert when score > X)

**Estimado:** 3 sprints | **Impacto en negocio:** Alto (feature premium, diferenciador)

---

## PLAN DE EJECUCIÓN — RESUMEN

| Fase | Tareas | Semanas | Prioridad | Blocker |
|------|--------|---------|-----------|---------|
| **1. Seguridad** | 1.1, 1.2, 1.3, 1.4 | 1 | 🔴 CRÍTICO | SÍ — producción |
| **2. Bugs lógica** | 2.1, 2.2, 2.3 | 2 | 🔴 CRÍTICO | SÍ — UX rota |
| **3. Discrepancias** | 3.1, 3.2, 3.3, 3.4, 3.5 | 2 | 🟠 ALTO | SÍ — offline roto |
| **4. Performance** | 4.1, 4.2 | 1 | 🟠 ALTO | Recomendado antes de escalar |
| **5. Features faltantes** | 5.1, 5.2, 5.3 | 3 | 🟡 MEDIO | No blocker |
| **6. Mejoras producto** | 6.1, 6.2, 6.3 | 4 | 🟡 MEDIO | No blocker |
| **7. Oportunidades** | 7.1-7.4 | 8-12 | 🔵 BAJO | Post-estabilización |

**Total estimado fases 1-6:** ~13 semanas de desarrollo  
**Costo de no hacerlo (Fases 1-3):** Vulnerabilidades explotables + datos offline corruptos + UX quebrada para cobradores en campo

---

## COMANDOS DE REFERENCIA

```bash
# Tests
php artisan test                          # Suite completa
php artisan test --testsuite=Feature     # Solo feature tests
php artisan test --filter=Security       # Filtrar por nombre

# Linting
./vendor/bin/phpstan analyse             # Static analysis
./vendor/bin/pint                        # Code style

# Migraciones
php artisan migrate                      # Aplicar pendientes
php artisan migrate:status               # Ver estado

# PWA Build
npm run build                            # Build de producción
npm run dev                              # Dev server

# Queue (requerido para jobs de sync)
php artisan queue:work                   # Procesar jobs
```
