# Cola offline de la PWA — Plan de implementación

> **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:** Que las tres colas offline (pagos, visitas, gastos) suban de verdad y que ningún cobro capturado en campo se pierda: el servidor lo aplica o lo retiene para revisión.

**Architecture:** En el servidor, el lote de pagos decide ítem por ítem (aplicar, duplicado, retener, error) y lo que no se puede aplicar con seguridad va a una tabla propia `held_payments` que el admin revisa en Filament. En el teléfono, un solo motor reemplaza las tres copias de sincronización, sin consultar booleanos por índice (la causa raíz), con un candado único, lotes, errores persistidos en IndexedDB y disparadores al entrar, al volver a primer plano, al volver la señal y periódicos.

**Tech Stack:** Laravel 13 / PHP 8.3, Filament 5, PHPUnit 11 (`DatabaseTransactions` sobre la BD dev `credify`), Vue 3 + Pinia, Dexie 4.4.4, `@sentry/vue` 10, Playwright 1.62.

**Spec:** `docs/superpowers/specs/2026-09-24-cola-offline-design.md` (leerla antes de empezar).

---

## Antes de empezar

- Trabajar en el worktree `/home/jredondo/credify-c`, rama `fix/pwa-cola-offline`. Todo comando corre **dentro de WSL** (`wsl bash -lc "cd /home/jredondo/credify-c && …"` o una terminal de WSL). `wsl bash -lc` rompe `$(...)`, `$var` y heredocs: para comandos con variables, escribir un script en `/home/jredondo/` y ejecutarlo.
- El worktree tiene su propio `vendor/` y `node_modules/` (no son symlinks; no crearlos como symlinks nunca).
- Los tests PHP comparten la BD dev `credify` y usan `DatabaseTransactions`. Todo archivo de test nuevo lleva el trait.
- Mensajes de commit terminan con `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`. Escribir el mensaje en `/home/jredondo/commit-msg.txt` y commitear con `git commit -F /home/jredondo/commit-msg.txt`.
- No usar nunca las palabras vetadas por el test de marca ("pagadiario", "gota a gota").

## Mapa de archivos

**Servidor — nuevos**
- `database/migrations/2026_09_24_120000_create_held_payments_table.php` — tabla de cobros retenidos.
- `app/Models/HeldPayment.php` — modelo, motivos, estados, posibles duplicados.
- `app/Services/Sync/CapturerResolver.php` — quién capturó un ítem de la cola.
- `app/Services/Payments/HeldPaymentReviewService.php` — aprobar / rechazar un retenido.
- `app/Services/Payments/HeldPaymentReviewException.php`
- `app/Policies/HeldPaymentPolicy.php`
- `app/Filament/Resources/HeldPayments/HeldPaymentResource.php`
- `app/Filament/Resources/HeldPayments/Pages/ListHeldPayments.php`
- `app/Filament/Resources/HeldPayments/Pages/ViewHeldPayment.php`
- `app/Filament/Resources/HeldPayments/Tables/HeldPaymentsTable.php`
- Tests: `tests/Feature/Pwa/CapturerResolverTest.php`, `tests/Feature/Pwa/SyncPaymentsHeldTest.php`, `tests/Feature/Pwa/SyncVisitsExpensesOfflineTest.php`, `tests/Feature/HeldPaymentReviewTest.php`, `tests/Feature/Filament/HeldPaymentResourceTest.php`, `tests/Feature/Pwa/SettingsLocaleTest.php`.

**Servidor — modificados**
- `app/Services/PaymentSyncService.php` — decisión por ítem, retención, `applyToCredit()` compartido.
- `app/Http/Requests/Pwa/SyncPaymentsRequest.php` — solo valida la forma del lote.
- `app/Http/Controllers/Api/Pwa/SyncController.php` — visitas y gastos por ítem, conversión simplificada, capturador.
- `app/Http/Controllers/Api/Pwa/Traits/RoleAwareQueries.php` — `pwaRoleFor(User)`.
- `app/Http/Controllers/Api/Pwa/SettingsController.php` — locale con guion.
- `app/Models/Expense.php` — `markPendingApproval()`.
- `app/Services/Payments/PaymentPolicyValidator.php` — docblock del principio.
- Tests existentes: `tests/Feature/SyncPaymentsTest.php`, `tests/Feature/SyncExpensesTest.php`, `tests/Feature/CreditParentChildTest.php`.

**PWA — modificados**
- `resources/js/pwa/db/index.js` — Dexie v8, sin `clearAll`.
- `resources/js/pwa/stores/sync.js` — motor único (reescritura).
- `resources/js/pwa/stores/auth.js` — login sube la cola antes de bajar la cartera, cambio de usuario sin borrar colas, Sentry `setUser`.
- `resources/js/pwa/stores/settings.js` — locale.
- `resources/js/pwa/main.js` — transporte offline de Sentry.
- `resources/js/pwa/App.vue` — disparadores.
- `resources/js/pwa/components/ui/SyncIndicator.vue`, `resources/js/pwa/components/home/CollectorHome.vue`, `resources/js/pwa/views/PaymentsHistoryView.vue`, `resources/js/pwa/views/ProfileView.vue`, `resources/js/pwa/views/SyncErrorsView.vue`.
- E2E: nuevo `tests/e2e/cola-offline.spec.js`; ajuste `tests/e2e/sync-purge.spec.js`.

---

### Task 1: Tabla y modelo de cobros retenidos

**Files:**
- Create: `database/migrations/2026_09_24_120000_create_held_payments_table.php`
- Create: `app/Models/HeldPayment.php`

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

```php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

/**
 * Cobros capturados en campo que la cola offline no pudo aplicar con seguridad
 * (PWA-001). Viven FUERA de `payments` a propósito: todo total, reporte y KPI
 * suma `payments`, y un cobro retenido no debe contar hasta que alguien lo apruebe.
 */
return new class extends Migration
{
    public function up(): void
    {
        Schema::create('held_payments', function (Blueprint $table) {
            $table->id();
            $table->foreignId('company_id')->constrained()->cascadeOnDelete();
            $table->uuid('idempotency_key');
            // Solo se enlaza si el crédito es de la misma empresa; el id crudo queda en `payload`.
            $table->foreignId('credit_id')->nullable()->constrained()->nullOnDelete();
            // Si se borra el usuario, la referencia queda en null (igual que en el
            // resto de la app: `credify:reset-demo` y el borrado desde /admin lo
            // necesitan); el id crudo sigue en `payload`.
            $table->foreignId('captured_by_user_id')->nullable()->constrained('users')->nullOnDelete();
            $table->foreignId('synced_by_user_id')->nullable()->constrained('users')->nullOnDelete();
            // Nulos solo si llegaron ilegibles (motivo `invalid`).
            $table->decimal('amount', 12, 2)->nullable();
            $table->string('payment_method', 20)->nullable();
            $table->date('payment_date')->nullable();
            $table->dateTime('offline_created_at')->nullable();
            $table->string('device_id')->nullable();
            $table->decimal('latitude', 10, 7)->nullable();
            $table->decimal('longitude', 10, 7)->nullable();
            $table->string('reason', 30);
            $table->string('reason_detail')->nullable();
            $table->json('payload');
            $table->string('status', 20)->default('pending');
            $table->foreignId('resolved_by_user_id')->nullable()->constrained('users')->nullOnDelete();
            $table->timestamp('resolved_at')->nullable();
            $table->text('resolution_notes')->nullable();
            $table->foreignId('payment_id')->nullable()->constrained()->nullOnDelete();
            $table->timestamps();

            $table->unique(['company_id', 'idempotency_key']);
            // Un retenido produce como mucho un pago.
            $table->unique('payment_id');
            $table->index(['company_id', 'status']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('held_payments');
    }
};
```

- [x] **Step 2: Crear el modelo**

```php
<?php

declare(strict_types=1);

namespace App\Models;

use App\Traits\MultiTenantScope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

/**
 * Cobro de campo que el lote offline no aplicó: llegó tarde, apunta a un crédito
 * que cambió, supera el saldo, lo capturó otro usuario del mismo teléfono o el
 * cobrador lo mandó a revisión. Un admin lo aprueba (se registra el pago) o lo
 * rechaza. Nunca se borra: es el rastro de plata que el cliente sí entregó.
 *
 * @property int $id
 * @property int $company_id
 * @property string $idempotency_key
 * @property int|null $credit_id
 * @property int $captured_by_user_id
 * @property int $synced_by_user_id
 * @property string|null $amount
 * @property string|null $payment_method
 * @property \Illuminate\Support\Carbon|null $payment_date
 * @property \Illuminate\Support\Carbon|null $offline_created_at
 * @property string|null $device_id
 * @property string|null $latitude
 * @property string|null $longitude
 * @property string $reason
 * @property string|null $reason_detail
 * @property array<string, mixed> $payload
 * @property string $status
 * @property int|null $resolved_by_user_id
 * @property \Illuminate\Support\Carbon|null $resolved_at
 * @property string|null $resolution_notes
 * @property int|null $payment_id
 */
class HeldPayment extends Model
{
    use MultiTenantScope;

    public const STATUS_PENDING = 'pending';

    public const STATUS_APPROVED = 'approved';

    public const STATUS_REJECTED = 'rejected';

    public const REASON_MANUAL = 'manual';

    public const REASON_FOREIGN_USER = 'foreign_user';

    public const REASON_INVALID = 'invalid';

    public const REASON_FUTURE_DATE = 'future_date';

    public const REASON_STALE = 'stale';

    public const REASON_CREDIT_UNAVAILABLE = 'credit_unavailable';

    public const REASON_EXCEEDS_BALANCE = 'exceeds_balance';

    /** Un cobro que llega con más días que estos se retiene (mismo umbral que StorePaymentRequest). */
    public const STALE_AFTER_DAYS = 7;

    /** Ventana, en días a cada lado de la fecha de cobro, para buscar posibles duplicados. */
    public const DUPLICATE_WINDOW_DAYS = 7;

    protected $fillable = [
        'company_id',
        'idempotency_key',
        'credit_id',
        'captured_by_user_id',
        'synced_by_user_id',
        'amount',
        'payment_method',
        'payment_date',
        'offline_created_at',
        'device_id',
        'latitude',
        'longitude',
        'reason',
        'reason_detail',
        'payload',
        'status',
        'resolved_by_user_id',
        'resolved_at',
        'resolution_notes',
        'payment_id',
    ];

    protected $casts = [
        'amount' => 'decimal:2',
        'payment_date' => 'date',
        'offline_created_at' => 'datetime',
        'resolved_at' => 'datetime',
        'payload' => 'array',
        'latitude' => 'decimal:7',
        'longitude' => 'decimal:7',
    ];

    public static function reasonLabel(string $reason): string
    {
        return match ($reason) {
            self::REASON_MANUAL => 'Enviado a revisión por el cobrador',
            self::REASON_FOREIGN_USER => 'Capturado por otro usuario del teléfono',
            self::REASON_INVALID => 'Datos ilegibles',
            self::REASON_FUTURE_DATE => 'Fecha de cobro futura',
            self::REASON_STALE => 'Llegó con más de 7 días de atraso',
            self::REASON_CREDIT_UNAVAILABLE => 'El crédito ya no admite el pago',
            self::REASON_EXCEEDS_BALANCE => 'Supera el saldo del crédito',
            default => $reason,
        };
    }

    public static function statusLabel(string $status): string
    {
        return match ($status) {
            self::STATUS_PENDING => 'Pendiente',
            self::STATUS_APPROVED => 'Aprobado',
            self::STATUS_REJECTED => 'Rechazado',
            default => $status,
        };
    }

    /** @return BelongsTo<Company, $this> */
    public function company(): BelongsTo
    {
        return $this->belongsTo(Company::class);
    }

    /** @return BelongsTo<Credit, $this> */
    public function credit(): BelongsTo
    {
        return $this->belongsTo(Credit::class);
    }

    /** @return BelongsTo<User, $this> */
    public function capturedBy(): BelongsTo
    {
        return $this->belongsTo(User::class, 'captured_by_user_id');
    }

    /** @return BelongsTo<User, $this> */
    public function syncedBy(): BelongsTo
    {
        return $this->belongsTo(User::class, 'synced_by_user_id');
    }

    /** @return BelongsTo<User, $this> */
    public function resolvedBy(): BelongsTo
    {
        return $this->belongsTo(User::class, 'resolved_by_user_id');
    }

    /** @return BelongsTo<Payment, $this> */
    public function payment(): BelongsTo
    {
        return $this->belongsTo(Payment::class);
    }

    /**
     * @param  Builder<HeldPayment>  $query
     * @return Builder<HeldPayment>
     */
    public function scopePending(Builder $query): Builder
    {
        return $query->where('status', self::STATUS_PENDING);
    }

    /**
     * Pagos vigentes del mismo crédito, por el mismo monto, cerca de la fecha de
     * cobro. Es la pista para el caso típico: el cobrador vio que la cola no
     * bajaba y volvió a cargar el pago a mano, con otra clave.
     *
     * @return Collection<int, Payment>
     */
    public function possibleDuplicates(): Collection
    {
        if ($this->credit_id === null || $this->amount === null || $this->payment_date === null) {
            return new Collection;
        }

        return Payment::query()
            ->where('company_id', $this->company_id)
            ->where('credit_id', $this->credit_id)
            ->collections()
            ->where('amount', $this->amount)
            ->whereBetween('payment_date', [
                $this->payment_date->copy()->subDays(self::DUPLICATE_WINDOW_DAYS)->toDateString(),
                $this->payment_date->copy()->addDays(self::DUPLICATE_WINDOW_DAYS)->toDateString(),
            ])
            ->when($this->payment_id !== null, fn ($q) => $q->where('id', '!=', $this->payment_id))
            ->orderBy('payment_date')
            ->get();
    }
}
```

- [x] **Step 2b: Incluir la tabla en el reset nocturno de la demo**

En `app/Console/Commands/ResetDemoCompany.php`, añadir `HeldPayment::class` al principio de `TENANT_MODELS` (se borra antes que usuarios y créditos) con su import, y dejar el docblock del comando fiel a la realidad. En el modelo, el rótulo de `REASON_STALE` se arma con la constante: `'Llegó con más de '.self::STALE_AFTER_DAYS.' días de atraso'`, y `captured_by_user_id` / `synced_by_user_id` son `int|null`.

- [x] **Step 3: Aplicar la migración a la BD dev (es aditiva; la comparten las otras sesiones)**

Run: `php artisan migrate`
Expected: `2026_09_24_120000_create_held_payments_table ... DONE`

- [x] **Step 4: Commit**

```bash
git add database/migrations/2026_09_24_120000_create_held_payments_table.php app/Models/HeldPayment.php
git commit -F /home/jredondo/commit-msg.txt   # "feat(sync): tabla held_payments para cobros retenidos"
```

---

### Task 2: Quién capturó un ítem de la cola

**Files:**
- Modify: `app/Http/Controllers/Api/Pwa/Traits/RoleAwareQueries.php:28-45`
- Create: `app/Services/Sync/CapturerResolver.php`
- Test: `tests/Feature/Pwa/CapturerResolverTest.php`

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Pwa;

use App\Models\Company;
use App\Models\User;
use App\Services\Sync\CapturerResolver;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;

/**
 * Cada ítem de la cola offline dice quién lo capturó (`captured_by_user_id`).
 * Un teléfono compartido sube la cola de quien lo usó antes con el token de
 * quien entra ahora, así que el servidor no puede atribuir por token.
 */
class CapturerResolverTest extends TestCase
{
    use DatabaseTransactions;

    #[Test]
    public function sin_capturador_es_quien_sube(): void
    {
        $company = Company::factory()->create();
        $syncer = User::factory()->create(['company_id' => $company->id]);

        $this->assertSame($syncer->id, app(CapturerResolver::class)->resolve(null, $syncer)?->id);
    }

    #[Test]
    public function otro_usuario_de_la_misma_empresa_es_el_capturador(): void
    {
        $company = Company::factory()->create();
        $syncer = User::factory()->create(['company_id' => $company->id]);
        $capturer = User::factory()->create(['company_id' => $company->id]);

        $this->assertSame($capturer->id, app(CapturerResolver::class)->resolve($capturer->id, $syncer)?->id);
    }

    #[Test]
    public function un_usuario_de_otra_empresa_o_inexistente_no_se_puede_atribuir(): void
    {
        $company = Company::factory()->create();
        $syncer = User::factory()->create(['company_id' => $company->id]);
        $foreigner = User::factory()->create(['company_id' => Company::factory()->create()->id]);

        $resolver = app(CapturerResolver::class);

        $this->assertNull($resolver->resolve($foreigner->id, $syncer));
        $this->assertNull($resolver->resolve(999_999_999, $syncer));
        $this->assertNull($resolver->resolve('no-es-un-id', $syncer));
    }
}
```

- [x] **Step 2: Correrlo y ver que falla**

Run: `php artisan test tests/Feature/Pwa/CapturerResolverTest.php`
Expected: FAIL — `Class "App\Services\Sync\CapturerResolver" not found`.

- [x] **Step 3: Crear el resolver**

```php
<?php

declare(strict_types=1);

namespace App\Services\Sync;

use App\Models\User;

/**
 * Quién capturó un ítem de la cola offline.
 *
 * El token dice quién SUBE, no quién COBRÓ: en un teléfono compartido la cola
 * de quien lo usó antes se sube con la sesión de quien entra después.
 */
class CapturerResolver
{
    /**
     * - Sin `captured_by_user_id` (ítems anteriores a este campo) o igual al que
     *   sube → el mismo usuario autenticado, como hasta ahora.
     * - Otro usuario de la MISMA empresa → ese usuario.
     * - Inexistente, ilegible o de otra empresa → null: no se puede atribuir.
     */
    public function resolve(mixed $capturedById, User $syncer): ?User
    {
        if ($capturedById === null || $capturedById === '') {
            return $syncer;
        }

        if (! is_numeric($capturedById)) {
            return null;
        }

        $id = (int) $capturedById;

        if ($id === $syncer->id) {
            return $syncer;
        }

        return User::query()
            ->withoutGlobalScopes()
            ->where('company_id', $syncer->company_id)
            ->find($id);
    }
}
```

- [x] **Step 4: Separar el rol de un usuario del Request en el trait**

En `RoleAwareQueries.php`, reemplazar el cuerpo de `getUserPwaRole` para que delegue, y añadir `pwaRoleFor` justo debajo:

```php
    protected function getUserPwaRole(Request $request): string
    {
        return $this->pwaRoleFor($request->user());
    }

    /**
     * Rol PWA de cualquier usuario (no solo del autenticado). La cola offline lo
     * necesita para el capturador de un ítem, que puede no ser quien sube.
     *
     * Orden de prioridad: admin > supervisor > collector.
     */
    protected function pwaRoleFor(User $user): string
    {
        $roles = $user->getRoleNames();

        if ($roles->contains('admin')) {
            return 'admin';
        }
        if ($roles->contains('supervisor')) {
            return 'supervisor';
        }
        if ($roles->contains('collector')) {
            return 'collector';
        }

        return 'unknown';
    }
```

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

Run: `php artisan test tests/Feature/Pwa/CapturerResolverTest.php tests/Feature/SyncPaymentsTest.php`
Expected: PASS (el segundo confirma que el trait sigue igual para los que ya lo usaban).

- [x] **Step 6: Commit**

```bash
git add app/Services/Sync/CapturerResolver.php app/Http/Controllers/Api/Pwa/Traits/RoleAwareQueries.php tests/Feature/Pwa/CapturerResolverTest.php
git commit -F /home/jredondo/commit-msg.txt   # "feat(sync): resolver quién capturó un ítem de la cola"
```

---

### Task 3: El lote de pagos decide ítem por ítem y retiene lo dudoso

**Files:**
- Modify: `app/Services/PaymentSyncService.php` (reescritura completa)
- Modify: `app/Http/Requests/Pwa/SyncPaymentsRequest.php:16-46`
- Modify: `app/Services/Payments/PaymentPolicyValidator.php:24-27` (docblock)
- Test: `tests/Feature/Pwa/SyncPaymentsHeldTest.php` (nuevo)
- Modify tests: `tests/Feature/SyncPaymentsTest.php`, `tests/Feature/CreditParentChildTest.php:199-217`

- [x] **Step 1: Escribir los tests nuevos**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Pwa;

use App\Models\Client;
use App\Models\Company;
use App\Models\Credit;
use App\Models\HeldPayment;
use App\Models\Installment;
use App\Models\Plan;
use App\Models\Subscription;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Str;
use Illuminate\Testing\TestResponse;
use PHPUnit\Framework\Attributes\Test;
use Spatie\Permission\Models\Role;
use Tests\TestCase;

/**
 * POST /api/pwa/sync/payments: un cobro capturado en campo nunca se pierde ni se
 * rechaza en silencio. Lo que no se puede aplicar con seguridad queda en
 * `held_payments` (sin tocar saldos) y el teléfono puede soltarlo. (PWA-001)
 */
class SyncPaymentsHeldTest extends TestCase
{
    use DatabaseTransactions;

    private Company $company;

    private User $collector;

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

        foreach (['admin', 'supervisor', 'collector'] as $role) {
            Role::firstOrCreate(['name' => $role, 'guard_name' => 'web']);
        }

        $plan = Plan::factory()->create();
        $this->company = Company::factory()->create();

        Subscription::factory()->create([
            'company_id' => $this->company->id,
            'plan_id' => $plan->id,
            'status' => Subscription::STATUS_ACTIVE,
            'is_active' => true,
            'starts_at' => now()->subMonth(),
            'ends_at' => now()->addYear(),
        ]);

        $this->collector = $this->makeUser('collector');
    }

    private function makeUser(string $role, ?Company $company = null): User
    {
        $user = User::factory()->create(['company_id' => ($company ?? $this->company)->id]);
        $user->assignRole($role);

        return $user->fresh();
    }

    private function makeCredit(User $collector, float $total = 60_000.0): Credit
    {
        $client = Client::factory()->create(['company_id' => $this->company->id]);

        $credit = Credit::factory()->create([
            'company_id' => $this->company->id,
            'client_id' => $client->id,
            'collector_user_id' => $collector->id,
            'created_by_user_id' => $collector->id,
            'status' => Credit::STATUS_ACTIVE,
            'amount' => $total,
            'installments_count' => 1,
            'periodicity' => 'monthly',
            'start_date' => now()->subDays(5),
            'due_date' => now()->addDays(25),
        ]);

        Installment::factory()->create([
            'company_id' => $this->company->id,
            'credit_id' => $credit->id,
            'installment_number' => 1,
            'status' => 'pending',
            'amount_paid' => 0,
            'total_amount' => $total,
            'principal_amount' => $total * 0.8,
            'interest_amount' => $total * 0.2,
            'due_date' => now()->addDays(25),
        ]);

        return $credit;
    }

    private function item(Credit $credit, array $overrides = []): array
    {
        return array_merge([
            'idempotency_key' => (string) Str::uuid(),
            'credit_id' => $credit->id,
            'amount' => 10_000.0,
            'payment_method' => 'cash',
            'payment_date' => now()->toDateString(),
            'created_at_local' => now()->toIso8601String(),
        ], $overrides);
    }

    private function sync(User $user, array $items): TestResponse
    {
        return $this->actingAs($user, 'sanctum')
            ->postJson('/api/pwa/sync/payments', ['payments' => $items]);
    }

    private function assertHeld(TestResponse $response, int $index, string $reason, array $item): HeldPayment
    {
        $response->assertOk();
        $this->assertSame('held', $response->json("results.{$index}.status"));
        $this->assertSame($reason, $response->json("results.{$index}.reason"));
        $this->assertDatabaseMissing('payments', ['idempotency_key' => $item['idempotency_key']]);

        return HeldPayment::withoutGlobalScopes()
            ->where('idempotency_key', $item['idempotency_key'])
            ->firstOrFail();
    }

    #[Test]
    public function un_item_ilegible_no_tumba_el_lote(): void
    {
        $credit = $this->makeCredit($this->collector);
        $bueno = $this->item($credit);
        $malo = $this->item($credit, ['amount' => 'abc']);
        $sinClave = $this->item($credit, ['idempotency_key' => 'no-es-uuid']);

        $response = $this->sync($this->collector, [$bueno, $malo, $sinClave]);

        $response->assertOk();
        $this->assertSame('success', $response->json('results.0.status'));
        $this->assertHeld($response, 1, HeldPayment::REASON_INVALID, $malo);
        // Sin clave válida no se puede deduplicar: es el único caso que vuelve como error.
        $this->assertSame('error', $response->json('results.2.status'));
        $this->assertSame(['total' => 3, 'success' => 1, 'duplicates' => 0, 'held' => 1, 'errors' => 1], $response->json('summary'));
    }

    #[Test]
    public function un_lote_de_51_se_rechaza_por_forma(): void
    {
        $credit = $this->makeCredit($this->collector);
        $items = array_map(fn () => $this->item($credit, ['amount' => 1]), range(1, 51));

        $this->sync($this->collector, $items)->assertUnprocessable();
    }

    #[Test]
    public function un_cobro_de_hace_mas_de_7_dias_queda_retenido_sin_tocar_el_saldo(): void
    {
        $credit = $this->makeCredit($this->collector);
        $saldoAntes = (float) $credit->fresh()->remaining_balance;
        $item = $this->item($credit, [
            'payment_date' => now()->subDays(10)->toDateString(),
            'created_at_local' => now()->subDays(10)->toIso8601String(),
        ]);

        $held = $this->assertHeld($this->sync($this->collector, [$item]), 0, HeldPayment::REASON_STALE, $item);

        $this->assertSame($saldoAntes, (float) $credit->fresh()->remaining_balance);
        $this->assertSame(HeldPayment::STATUS_PENDING, $held->status);
        $this->assertSame($credit->id, $held->credit_id);
        $this->assertSame($this->collector->id, $held->captured_by_user_id);
        $this->assertSame('10000.00', $held->amount);
    }

    #[Test]
    public function tambien_se_retiene_si_se_capturo_hace_mas_de_7_dias_aunque_la_fecha_sea_hoy(): void
    {
        $credit = $this->makeCredit($this->collector);
        $item = $this->item($credit, ['created_at_local' => now()->subDays(9)->toIso8601String()]);

        $this->assertHeld($this->sync($this->collector, [$item]), 0, HeldPayment::REASON_STALE, $item);
    }

    #[Test]
    public function una_fecha_futura_queda_retenida(): void
    {
        $credit = $this->makeCredit($this->collector);
        $item = $this->item($credit, ['payment_date' => now()->addDays(2)->toDateString()]);

        $this->assertHeld($this->sync($this->collector, [$item]), 0, HeldPayment::REASON_FUTURE_DATE, $item);
    }

    #[Test]
    public function el_credito_de_otro_cobrador_queda_retenido(): void
    {
        $credit = $this->makeCredit($this->makeUser('collector'));
        $item = $this->item($credit);

        $held = $this->assertHeld($this->sync($this->collector, [$item]), 0, HeldPayment::REASON_CREDIT_UNAVAILABLE, $item);
        $this->assertSame($credit->id, $held->credit_id);
    }

    #[Test]
    public function un_credito_pagado_queda_retenido(): void
    {
        $credit = $this->makeCredit($this->collector);
        $credit->update(['status' => Credit::STATUS_PAID]);
        $item = $this->item($credit);

        $this->assertHeld($this->sync($this->collector, [$item]), 0, HeldPayment::REASON_CREDIT_UNAVAILABLE, $item);
    }

    #[Test]
    public function un_monto_mayor_que_el_saldo_queda_retenido(): void
    {
        $credit = $this->makeCredit($this->collector, 60_000.0);
        $item = $this->item($credit, ['amount' => 90_000.0]);

        $this->assertHeld($this->sync($this->collector, [$item]), 0, HeldPayment::REASON_EXCEEDS_BALANCE, $item);
    }

    #[Test]
    public function el_cobrador_puede_mandarlo_a_revision(): void
    {
        $credit = $this->makeCredit($this->collector);
        $item = $this->item($credit, ['hold' => true]);

        $this->assertHeld($this->sync($this->collector, [$item]), 0, HeldPayment::REASON_MANUAL, $item);
    }

    #[Test]
    public function reenviar_un_retenido_no_crea_otro(): void
    {
        $credit = $this->makeCredit($this->collector);
        $item = $this->item($credit, ['hold' => true]);

        $this->sync($this->collector, [$item])->assertOk();
        $response = $this->sync($this->collector, [$item]);

        $this->assertSame('held', $response->json('results.0.status'));
        $this->assertSame(1, HeldPayment::withoutGlobalScopes()->where('idempotency_key', $item['idempotency_key'])->count());
    }

    #[Test]
    public function la_cola_de_otro_usuario_del_telefono_queda_retenida_a_su_nombre(): void
    {
        $anterior = $this->makeUser('collector');
        $credit = $this->makeCredit($anterior);
        $item = $this->item($credit, ['captured_by_user_id' => $anterior->id]);

        $held = $this->assertHeld($this->sync($this->collector, [$item]), 0, HeldPayment::REASON_FOREIGN_USER, $item);

        $this->assertSame($anterior->id, $held->captured_by_user_id);
        $this->assertSame($this->collector->id, $held->synced_by_user_id);
    }

    #[Test]
    public function un_capturador_de_otra_empresa_queda_retenido_como_invalido_a_nombre_de_quien_sube(): void
    {
        $ajeno = $this->makeUser('collector', Company::factory()->create());
        $credit = $this->makeCredit($this->collector);
        $item = $this->item($credit, ['captured_by_user_id' => $ajeno->id]);

        $held = $this->assertHeld($this->sync($this->collector, [$item]), 0, HeldPayment::REASON_INVALID, $item);

        $this->assertSame($this->collector->id, $held->captured_by_user_id);
    }

    #[Test]
    public function el_pago_aplicado_guarda_la_hora_offline_y_cierra_el_credito_si_se_paga_todo(): void
    {
        $credit = $this->makeCredit($this->collector, 60_000.0);
        $item = $this->item($credit, ['amount' => 60_000.0]);

        $response = $this->sync($this->collector, [$item]);

        $response->assertOk();
        $this->assertSame('success', $response->json('results.0.status'));
        $this->assertTrue($response->json('results.0.credit_is_paid'));
        $this->assertNotNull(
            \App\Models\Payment::withoutGlobalScopes()->where('idempotency_key', $item['idempotency_key'])->value('offline_created_at')
        );
    }
}
```

- [x] **Step 2: Correrlos y ver que fallan**

Run: `php artisan test tests/Feature/Pwa/SyncPaymentsHeldTest.php`
Expected: FAIL. `un_item_ilegible_no_tumba_el_lote` recibe 422 (el FormRequest valida cada ítem). Los de retención reciben `error` en vez de `held`. El de lote de 51 pasa ya (la regla `max:50` existe).

- [x] **Step 3: Dejar el FormRequest solo con la forma del lote**

En `SyncPaymentsRequest.php`, reemplazar `rules()` y `messages()`:

```php
    /**
     * Solo la FORMA del lote. Cada ítem se valida en PaymentSyncService, que
     * responde por ítem: antes un solo ítem malo devolvía 422 al lote completo,
     * sin decir cuál era, y el teléfono lo reenviaba igual para siempre.
     */
    public function rules(): array
    {
        return [
            'payments' => ['required', 'array', 'min:1', 'max:50'],
            'payments.*' => ['array'],
        ];
    }

    public function messages(): array
    {
        return [
            'payments.required' => 'Se requiere al menos un pago para sincronizar.',
            'payments.max' => 'Máximo 50 pagos por sincronización.',
        ];
    }
```

- [x] **Step 4: Reescribir `PaymentSyncService`**

Reemplazar el archivo completo:

```php
<?php

declare(strict_types=1);

namespace App\Services;

use App\Models\CompanyFinancialSettings;
use App\Models\Credit;
use App\Models\HeldPayment;
use App\Models\Payment;
use App\Models\User;
use App\Services\Sync\CapturerResolver;
use App\ValueObjects\Money;
use Carbon\Carbon;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;

/**
 * Lógica de POST /api/pwa/sync/payments (la cola offline de cobros).
 *
 * Principio: un cobro capturado en campo nunca se pierde ni se rechaza en
 * silencio. Cada ítem termina en uno de estos estados:
 *   - success   → se aplicó al crédito.
 *   - duplicate → ya existía un pago con esa clave.
 *   - held      → no se podía aplicar con seguridad y quedó en `held_payments`
 *                 para que un admin decida. No toca saldos.
 *   - error     → solo si falta la clave de idempotencia o no es un UUID (sin
 *                 ella no hay forma de deduplicar), o por un fallo interno.
 * El teléfono suelta el ítem con success, duplicate o held.
 */
class PaymentSyncService
{
    public function __construct(
        private readonly PaymentManager $paymentManager,
        private readonly CreditStatusSyncService $creditStatusSyncService,
        private readonly CapturerResolver $capturerResolver,
    ) {}

    /**
     * @param  array<int, mixed>  $payments  ítems crudos del lote (forma validada por SyncPaymentsRequest)
     * @param  string  $role  'collector' | 'supervisor' | 'admin'
     * @return array{results: list<array<string, mixed>>, summary: array{total: int, success: int, duplicates: int, held: int, errors: int}}
     */
    public function processBatch(array $payments, User $user, string $role): array
    {
        $results = [];
        $counts = ['success' => 0, 'duplicate' => 0, 'held' => 0, 'error' => 0];

        foreach ($payments as $raw) {
            $item = is_array($raw) ? $raw : [];

            try {
                $result = $this->processSingle($item, $user, $role);
            } catch (\Throwable $e) {
                Log::error('PaymentSyncService: unexpected error', [
                    'idempotency_key' => $item['idempotency_key'] ?? null,
                    'error' => $e->getMessage(),
                    'user_id' => $user->id,
                ]);

                $result = [
                    'idempotency_key' => $item['idempotency_key'] ?? null,
                    'status' => 'error',
                    'error' => 'Error interno al procesar pago.',
                ];
            }

            $results[] = $result;
            $counts[$result['status']]++;
        }

        return [
            'results' => $results,
            'summary' => [
                'total' => count($payments),
                'success' => $counts['success'],
                'duplicates' => $counts['duplicate'],
                'held' => $counts['held'],
                'errors' => $counts['error'],
            ],
        ];
    }

    /**
     * Registra un cobro de campo sobre un crédito ya validado. Lo usan el lote y
     * la aprobación de un retenido, para que los dos caminos dejen el pago igual.
     *
     * @param  array{amount: float, payment_date: string, payment_method: ?string, device_id: ?string, offline_created_at: Carbon|string|null, latitude: mixed, longitude: mixed}  $data
     */
    public function applyToCredit(Credit $credit, array $data, int $registeredByUserId, string $idempotencyKey): Payment
    {
        $payment = DB::transaction(function () use ($credit, $data, $registeredByUserId, $idempotencyKey) {
            $payment = $this->paymentManager->registerPayment(
                amount: (float) $data['amount'],
                paymentDate: Carbon::parse($data['payment_date']),
                registeredByUserId: $registeredByUserId,
                credit: $credit,
                paymentMethod: $data['payment_method'] ?? null,
            );

            $payment->idempotency_key = $idempotencyKey;
            $payment->device_id = $data['device_id'] ?? null;
            $payment->offline_created_at = $data['offline_created_at'] ?? null;
            $payment->latitude = $data['latitude'] ?? null;
            $payment->longitude = $data['longitude'] ?? null;
            $payment->save();

            return $payment;
        });

        // Igual que PaymentController: el cierre síncrono respeta la política de la empresa.
        $credit->refresh();
        $settings = CompanyFinancialSettings::forCompany($credit->company_id);
        if ((float) $credit->remaining_balance <= 0 && $settings->auto_close_on_full_payment) {
            $this->creditStatusSyncService->forceSync($credit);
            $credit->refresh();
        }

        return $payment;
    }

    /**
     * @param  array<string, mixed>  $item
     * @return array<string, mixed>
     */
    private function processSingle(array $item, User $user, string $role): array
    {
        $key = $item['idempotency_key'] ?? null;

        // ── 1. Sin clave válida no se puede deduplicar ───────────────────────
        if (! is_string($key) || ! Str::isUuid($key)) {
            return [
                'idempotency_key' => $key,
                'status' => 'error',
                'error' => 'Clave de idempotencia inválida.',
            ];
        }

        // ── 2. Ya procesado ──────────────────────────────────────────────────
        $existing = Payment::query()
            ->where('company_id', $user->company_id)
            ->where('idempotency_key', $key)
            ->first();

        if ($existing) {
            return $this->duplicateResult($existing);
        }

        $alreadyHeld = HeldPayment::query()
            ->withoutGlobalScopes()
            ->where('company_id', $user->company_id)
            ->where('idempotency_key', $key)
            ->first();

        if ($alreadyHeld) {
            return $this->heldResult($alreadyHeld);
        }

        // ── 3. ¿Se puede aplicar con seguridad? ──────────────────────────────
        $capturer = $this->capturerResolver->resolve($item['captured_by_user_id'] ?? null, $user);
        [$reason, $detail, $credit] = $this->holdReason($item, $user, $role, $capturer);

        if ($reason !== null || $credit === null) {
            return $this->hold($item, $key, $user, $capturer, $reason ?? HeldPayment::REASON_INVALID, $detail);
        }

        // ── 4. Aplicar ───────────────────────────────────────────────────────
        try {
            $payment = $this->applyToCredit($credit, [
                'amount' => (float) $item['amount'],
                'payment_date' => (string) $item['payment_date'],
                'payment_method' => $item['payment_method'] ?? null,
                'device_id' => $item['device_id'] ?? null,
                'offline_created_at' => $this->localDateTime($item['created_at_local'] ?? null),
                'latitude' => $item['latitude'] ?? null,
                'longitude' => $item['longitude'] ?? null,
            ], $user->id, $key);
        } catch (QueryException $e) {
            // Race de idempotencia (índice único): otro request registró este pago
            // entre el SELECT de dedup y el INSERT. Es un duplicado, no un error.
            if ((int) ($e->errorInfo[1] ?? 0) === 1062) {
                $existing = Payment::query()
                    ->where('company_id', $user->company_id)
                    ->where('idempotency_key', $key)
                    ->first();

                if ($existing) {
                    return $this->duplicateResult($existing);
                }
            }

            throw $e;
        }

        Log::info('PaymentSyncService: payment synced', [
            'payment_id' => $payment->id,
            'idempotency_key' => $key,
            'credit_id' => $credit->id,
            'amount' => $item['amount'],
            'user_id' => $user->id,
        ]);

        return [
            'idempotency_key' => $key,
            'status' => 'success',
            'payment_id' => $payment->id,
            'credit_new_status' => $credit->status,
            'credit_is_paid' => $credit->status === Credit::STATUS_PAID,
        ];
    }

    /**
     * Motivo por el que un ítem NO se aplica, en el orden de la spec.
     *
     * @param  array<string, mixed>  $item
     * @return array{0: ?string, 1: ?string, 2: ?Credit}  [motivo, detalle, crédito aplicable]
     */
    private function holdReason(array $item, User $user, string $role, ?User $capturer): array
    {
        if (($item['hold'] ?? false) === true) {
            return [HeldPayment::REASON_MANUAL, 'El cobrador lo envió a revisión desde el teléfono.', null];
        }

        if ($capturer === null) {
            return [HeldPayment::REASON_INVALID, 'El usuario que capturó el cobro no pertenece a la empresa.', null];
        }

        if ($capturer->id !== $user->id) {
            return [HeldPayment::REASON_FOREIGN_USER, "Lo capturó {$capturer->name} y lo subió {$user->name} desde el mismo teléfono.", null];
        }

        $validator = Validator::make($item, [
            'credit_id' => ['required', 'integer'],
            'amount' => ['required', 'numeric', 'gt:0'],
            'payment_method' => ['nullable', 'string', 'in:cash,transfer,mobile'],
            'payment_date' => ['required', 'date'],
            'created_at_local' => ['nullable', 'date'],
            'device_id' => ['nullable', 'string', 'max:255'],
            'latitude' => ['nullable', 'numeric', 'between:-90,90'],
            'longitude' => ['nullable', 'numeric', 'between:-180,180'],
        ]);

        if ($validator->fails()) {
            return [HeldPayment::REASON_INVALID, $validator->errors()->first(), null];
        }

        $paymentDate = Carbon::parse((string) $item['payment_date'])->startOfDay();
        if ($paymentDate->greaterThan(Carbon::today())) {
            return [HeldPayment::REASON_FUTURE_DATE, "Fecha de cobro {$paymentDate->toDateString()}, posterior a hoy.", null];
        }

        $limit = Carbon::today()->subDays(HeldPayment::STALE_AFTER_DAYS);
        $capturedAt = $this->localDateTime($item['created_at_local'] ?? null);
        if ($paymentDate->lessThan($limit) || ($capturedAt !== null && $capturedAt->lessThan($limit))) {
            return [HeldPayment::REASON_STALE, 'Llegó con más de '.HeldPayment::STALE_AFTER_DAYS.' días de atraso.', null];
        }

        $query = Credit::query()
            ->where('company_id', $user->company_id)
            ->activeLeafCredits();

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

        $credit = $query->find((int) $item['credit_id']);

        if (! $credit) {
            return [HeldPayment::REASON_CREDIT_UNAVAILABLE, 'El crédito ya no está activo, fue reemplazado, cambió de cobrador o no existe.', null];
        }

        // Comparación exacta con Money VO (BC Math); sin tolerancias mágicas.
        if (Money::of((float) $item['amount'])->greaterThan(Money::of((float) $credit->remaining_balance))) {
            return [HeldPayment::REASON_EXCEEDS_BALANCE, "Monto {$item['amount']} mayor que el saldo {$credit->remaining_balance}.", null];
        }

        return [null, null, $credit];
    }

    /**
     * @param  array<string, mixed>  $item
     * @return array<string, mixed>
     */
    private function hold(array $item, string $key, User $syncer, ?User $capturer, string $reason, ?string $detail): array
    {
        $creditId = isset($item['credit_id']) && is_numeric($item['credit_id'])
            ? Credit::query()
                ->withoutGlobalScopes()
                ->where('company_id', $syncer->company_id)
                ->whereKey((int) $item['credit_id'])
                ->value('id')
            : null;

        $method = $item['payment_method'] ?? null;

        try {
            $held = HeldPayment::create([
                'company_id' => $syncer->company_id,
                'idempotency_key' => $key,
                'credit_id' => $creditId,
                'captured_by_user_id' => ($capturer ?? $syncer)->id,
                'synced_by_user_id' => $syncer->id,
                // Un monto que no cabe en decimal(12,2) haría fallar el INSERT y el
                // cobro se perdería como `error`: se retiene sin monto (motivo `invalid`).
                'amount' => isset($item['amount']) && is_numeric($item['amount']) && (float) $item['amount'] > 0 && (float) $item['amount'] <= 9_999_999_999.99 ? (float) $item['amount'] : null,
                'payment_method' => in_array($method, ['cash', 'transfer', 'mobile'], true) ? $method : null,
                'payment_date' => $this->dateOrNull($item['payment_date'] ?? null),
                'offline_created_at' => $this->localDateTime($item['created_at_local'] ?? null),
                'device_id' => is_string($item['device_id'] ?? null) ? Str::limit($item['device_id'], 255, '') : null,
                'latitude' => isset($item['latitude']) && is_numeric($item['latitude']) && abs((float) $item['latitude']) <= 90 ? $item['latitude'] : null,
                'longitude' => isset($item['longitude']) && is_numeric($item['longitude']) && abs((float) $item['longitude']) <= 180 ? $item['longitude'] : null,
                'reason' => $reason,
                'reason_detail' => $detail !== null ? Str::limit($detail, 255, '') : null,
                'payload' => $item,
                'status' => HeldPayment::STATUS_PENDING,
            ]);
        } catch (QueryException $e) {
            // Dos subidas simultáneas del mismo ítem: el índice único gana, es el mismo retenido.
            if ((int) ($e->errorInfo[1] ?? 0) !== 1062) {
                throw $e;
            }

            $held = HeldPayment::query()
                ->withoutGlobalScopes()
                ->where('company_id', $syncer->company_id)
                ->where('idempotency_key', $key)
                ->firstOrFail();
        }

        Log::warning('PaymentSyncService: payment held for review', [
            'held_payment_id' => $held->id,
            'idempotency_key' => $key,
            'reason' => $reason,
            'user_id' => $syncer->id,
        ]);

        return $this->heldResult($held);
    }

    /** @return array<string, mixed> */
    private function duplicateResult(Payment $payment): array
    {
        return [
            'idempotency_key' => $payment->idempotency_key,
            'status' => 'duplicate',
            'payment_id' => $payment->id,
            'message' => 'Pago ya registrado anteriormente.',
        ];
    }

    /** @return array<string, mixed> */
    private function heldResult(HeldPayment $held): array
    {
        return [
            'idempotency_key' => $held->idempotency_key,
            'status' => 'held',
            'held_id' => $held->id,
            'reason' => $held->reason,
        ];
    }

    /** La hora del teléfono llega en ISO con zona; se guarda en la zona de la app. */
    private function localDateTime(mixed $value): ?Carbon
    {
        if (! is_string($value) || $value === '') {
            return null;
        }

        try {
            return Carbon::parse($value)->setTimezone((string) config('app.timezone'));
        } catch (\Throwable) {
            return null;
        }
    }

    private function dateOrNull(mixed $value): ?string
    {
        if (! is_string($value) || $value === '') {
            return null;
        }

        try {
            return Carbon::parse($value)->toDateString();
        } catch (\Throwable) {
            return null;
        }
    }
}
```

- [x] **Step 5: Actualizar el docblock de `PaymentPolicyValidator` (líneas 24-26)**

Reemplazar el párrafo "El flujo offline por lotes …" por:

```php
 * El flujo offline por lotes (PaymentSyncService) NO usa este validador a
 * propósito: los pagos ya fueron capturados en campo y rechazarlos al
 * sincronizar sería perder rastro de plata cobrada. Lo que el lote no puede
 * aplicar con seguridad (saldo excedido, crédito que cambió, llegada tardía)
 * queda retenido en `held_payments` para que un admin decida.
```

- [x] **Step 6: Ajustar los tests existentes al nuevo comportamiento**

En `tests/Feature/SyncPaymentsTest.php`:
- Docblock de la clase, líneas 25-33: los puntos 3, 4, 5 y 6 pasan a "→ held (retenido para revisión)".
- `collector_cannot_pay_another_collectors_credit` (líneas 210-213): reemplazar las tres aserciones por:

```php
        $response->assertOk();
        $this->assertSame('held', $response->json('results.0.status'));
        $this->assertSame('credit_unavailable', $response->json('results.0.reason'));
        $this->assertSame(1, $response->json('summary.held'));
        $this->assertSame(0, $response->json('summary.success'));
```
- `amount_exceeding_remaining_balance_returns_error` (líneas 231-233): renombrar a `amount_exceeding_remaining_balance_is_held` y reemplazar por:

```php
        $response->assertOk();
        $this->assertSame('held', $response->json('results.0.status'));
        $this->assertSame('exceeds_balance', $response->json('results.0.reason'));
```
- `cross_company_credit_returns_error_and_does_not_leak_data` (línea 252): renombrar a `credit_of_a_foreign_collector_is_held_and_not_applied` y reemplazar la aserción por la de abajo. Ojo: pese al nombre, el helper `makeCreditWithInstallment` crea SIEMPRE el crédito en `$this->company`; lo ajeno es el cobrador. El retenido queda en la empresa de quien sube:

```php
        $this->assertSame('held', $response->json('results.0.status'));
        $this->assertSame('credit_unavailable', $response->json('results.0.reason'));
        $this->assertDatabaseHas('held_payments', [
            'idempotency_key' => $payload['idempotency_key'],
            'company_id' => $this->company->id,
        ]);
```
- `partial_batch_processes_valid_items_and_reports_errors_for_invalid` (líneas 272-276): reemplazar por:

```php
        $summary = $response->json('summary');
        $this->assertSame(2, $summary['total']);
        $this->assertSame(1, $summary['success']);
        $this->assertSame(1, $summary['held']);
        $this->assertSame(0, $summary['errors']);
        $this->assertSame(0, $summary['duplicates']);
```

En `tests/Feature/CreditParentChildTest.php` (líneas 212-214), reemplazar:

```php
        $response->assertOk();
        $this->assertSame('held', $response->json('results.0.status'),
            'Un pago a un crédito padre no se aplica: queda retenido para revisión');
        $this->assertSame('credit_unavailable', $response->json('results.0.reason'));
```

- [x] **Step 7: Correr los tests del lote**

Run: `php artisan test tests/Feature/Pwa/SyncPaymentsHeldTest.php tests/Feature/SyncPaymentsTest.php tests/Feature/CreditParentChildTest.php tests/Feature/Security/SyncPaymentRoleGuardTest.php`
Expected: PASS. `SyncPaymentRoleGuardTest` no cambia: el supervisor sigue en 403 y el collector/admin en 200 (el crédito 999 ahora queda retenido).

- [x] **Step 8: Commit**

```bash
git add app/Services/PaymentSyncService.php app/Http/Requests/Pwa/SyncPaymentsRequest.php app/Services/Payments/PaymentPolicyValidator.php tests/Feature/Pwa/SyncPaymentsHeldTest.php tests/Feature/SyncPaymentsTest.php tests/Feature/CreditParentChildTest.php
git commit -F /home/jredondo/commit-msg.txt   # "fix(sync): el lote de pagos decide por ítem y retiene lo que no puede aplicar"
```

---

### Task 4: Visitas y gastos por ítem, montos simplificados y capturador (PWA-010)

**Files:**
- Modify: `app/Http/Controllers/Api/Pwa/SyncController.php:369-618` (`syncVisits` y `syncExpenses`)
- Modify: `app/Models/Expense.php` (añadir `markPendingApproval()` junto a `applyApprovalPolicyForCreator`, ~línea 656)
- Test: `tests/Feature/Pwa/SyncVisitsExpensesOfflineTest.php` (nuevo)
- Modify test: `tests/Feature/SyncExpensesTest.php:148-165`

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Pwa;

use App\Models\Client;
use App\Models\Company;
use App\Models\CompanyFinancialSettings;
use App\Models\Credit;
use App\Models\Installment;
use App\Models\Plan;
use App\Models\Subscription;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Str;
use PHPUnit\Framework\Attributes\Test;
use Spatie\Permission\Models\Role;
use Tests\TestCase;

/**
 * Colas de visitas y gastos: un ítem malo no tumba el lote, los montos escritos
 * en modo simplificado se convierten (PWA-010) y cada ítem se atribuye a quien
 * lo capturó, no a quien lo sube.
 */
class SyncVisitsExpensesOfflineTest extends TestCase
{
    use DatabaseTransactions;

    private Company $company;

    private User $collector;

    private User $admin;

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

        foreach (['admin', 'supervisor', 'collector'] as $role) {
            Role::firstOrCreate(['name' => $role, 'guard_name' => 'web']);
        }

        $plan = Plan::factory()->create();
        $this->company = Company::factory()->create();

        Subscription::factory()->create([
            'company_id' => $this->company->id,
            'plan_id' => $plan->id,
            'status' => Subscription::STATUS_ACTIVE,
            'is_active' => true,
            'starts_at' => now()->subMonth(),
            'ends_at' => now()->addYear(),
        ]);

        $this->collector = $this->makeUser('collector');
        $this->admin = $this->makeUser('admin');
    }

    private function makeUser(string $role, ?Company $company = null): User
    {
        $user = User::factory()->create(['company_id' => ($company ?? $this->company)->id]);
        $user->assignRole($role);

        return $user->fresh();
    }

    private function simplifiedMode(bool $enabled): void
    {
        $this->actingAs($this->admin);
        CompanyFinancialSettings::forCompany($this->company->id)->update([
            'simplified_input_enabled' => $enabled,
            'simplified_input_multiplier' => 1000,
        ]);
    }

    private function makeCredit(User $collector): Credit
    {
        $client = Client::factory()->create(['company_id' => $this->company->id]);

        $credit = Credit::factory()->create([
            'company_id' => $this->company->id,
            'client_id' => $client->id,
            'collector_user_id' => $collector->id,
            'created_by_user_id' => $collector->id,
            'status' => Credit::STATUS_ACTIVE,
            'amount' => 60_000,
            'installments_count' => 1,
            'periodicity' => 'monthly',
            'start_date' => now()->subDays(5),
            'due_date' => now()->addDays(25),
        ]);

        Installment::factory()->create([
            'company_id' => $this->company->id,
            'credit_id' => $credit->id,
            'installment_number' => 1,
            'status' => 'pending',
            'amount_paid' => 0,
            'total_amount' => 60_000,
            'principal_amount' => 48_000,
            'interest_amount' => 12_000,
            'due_date' => now()->addDays(25),
        ]);

        return $credit;
    }

    private function expense(array $overrides = []): array
    {
        return array_merge([
            'idempotency_key' => (string) Str::uuid(),
            'amount' => 50,
            'category' => 'transportation',
            'notes' => null,
            'date' => now()->toDateString(),
        ], $overrides);
    }

    private function visit(Credit $credit, array $overrides = []): array
    {
        return array_merge([
            'idempotency_key' => (string) Str::uuid(),
            'credit_id' => $credit->id,
            'visit_type' => 'no_payment',
            'reason' => 'no_money',
            'visit_date' => now()->toDateString(),
            'promised_amount' => null,
        ], $overrides);
    }

    #[Test]
    public function un_gasto_capturado_en_modo_simplificado_se_convierte(): void
    {
        $this->simplifiedMode(true);
        $item = $this->expense(['amount' => 50, 'is_simplified_amount' => true]);

        $this->actingAs($this->collector, 'sanctum')
            ->postJson('/api/pwa/sync/expenses', ['expenses' => [$item]])
            ->assertOk()
            ->assertJsonPath('results.0.status', 'ok');

        $this->assertDatabaseHas('expenses', ['idempotency_key' => $item['idempotency_key'], 'amount' => 50_000]);
    }

    #[Test]
    public function se_convierte_aunque_el_modo_este_apagado_al_subir(): void
    {
        // Se capturó en modo simplificado y la empresa lo apagó antes de que subiera:
        // el flag describe cómo se escribió el número, no cómo está la empresa hoy.
        $this->simplifiedMode(false);
        $item = $this->expense(['amount' => 50, 'is_simplified_amount' => true]);

        $this->actingAs($this->collector, 'sanctum')
            ->postJson('/api/pwa/sync/expenses', ['expenses' => [$item]])
            ->assertOk();

        $this->assertDatabaseHas('expenses', ['idempotency_key' => $item['idempotency_key'], 'amount' => 50_000]);
    }

    #[Test]
    public function sin_flag_el_monto_no_se_toca(): void
    {
        $this->simplifiedMode(true);
        $item = $this->expense(['amount' => 50]);

        $this->actingAs($this->collector, 'sanctum')
            ->postJson('/api/pwa/sync/expenses', ['expenses' => [$item]])
            ->assertOk();

        $this->assertDatabaseHas('expenses', ['idempotency_key' => $item['idempotency_key'], 'amount' => 50]);
    }

    #[Test]
    public function la_promesa_de_una_visita_en_modo_simplificado_se_convierte(): void
    {
        $this->simplifiedMode(true);
        $credit = $this->makeCredit($this->collector);
        $item = $this->visit($credit, ['promised_amount' => 200, 'is_simplified_amount' => true]);

        $this->actingAs($this->collector, 'sanctum')
            ->postJson('/api/pwa/sync/visits', ['visits' => [$item]])
            ->assertOk()
            ->assertJsonPath('results.0.status', 'success');

        $this->assertDatabaseHas('collection_visits', ['idempotency_key' => $item['idempotency_key'], 'promised_amount' => 200_000]);
    }

    #[Test]
    public function el_gasto_de_otro_usuario_se_le_atribuye_y_queda_pendiente_aunque_suba_un_admin(): void
    {
        $item = $this->expense(['captured_by_user_id' => $this->collector->id]);

        $this->actingAs($this->admin, 'sanctum')
            ->postJson('/api/pwa/sync/expenses', ['expenses' => [$item]])
            ->assertOk()
            ->assertJsonPath('results.0.status', 'ok');

        $this->assertDatabaseHas('expenses', [
            'idempotency_key' => $item['idempotency_key'],
            'user_id' => $this->collector->id,
            'requires_approval' => 1,
            'approval_status' => 'pending',
        ]);
    }

    #[Test]
    public function un_capturador_de_otra_empresa_es_un_error_del_item(): void
    {
        $ajeno = $this->makeUser('collector', Company::factory()->create());
        $item = $this->expense(['captured_by_user_id' => $ajeno->id]);

        $this->actingAs($this->collector, 'sanctum')
            ->postJson('/api/pwa/sync/expenses', ['expenses' => [$item]])
            ->assertOk()
            ->assertJsonPath('results.0.status', 'error');

        $this->assertDatabaseMissing('expenses', ['idempotency_key' => $item['idempotency_key']]);
    }

    #[Test]
    public function la_visita_de_otro_cobrador_se_le_atribuye_y_su_acceso_se_mira_contra_el(): void
    {
        $anterior = $this->makeUser('collector');
        $credit = $this->makeCredit($anterior);
        $item = $this->visit($credit, ['captured_by_user_id' => $anterior->id]);

        // Quien sube es otro cobrador, sin acceso a ese crédito: igual entra, porque la capturó $anterior.
        $this->actingAs($this->collector, 'sanctum')
            ->postJson('/api/pwa/sync/visits', ['visits' => [$item]])
            ->assertOk()
            ->assertJsonPath('results.0.status', 'success');

        $this->assertDatabaseHas('collection_visits', [
            'idempotency_key' => $item['idempotency_key'],
            'collector_user_id' => $anterior->id,
        ]);
    }

    #[Test]
    public function una_visita_mala_no_tumba_el_lote(): void
    {
        $credit = $this->makeCredit($this->collector);
        $buena = $this->visit($credit);
        $mala = $this->visit($credit, ['visit_type' => 'inventado']);

        $response = $this->actingAs($this->collector, 'sanctum')
            ->postJson('/api/pwa/sync/visits', ['visits' => [$buena, $mala]]);

        $response->assertOk();
        $this->assertSame('success', $response->json('results.0.status'));
        $this->assertSame('error', $response->json('results.1.status'));
        $this->assertDatabaseHas('collection_visits', ['idempotency_key' => $buena['idempotency_key']]);
    }
}
```

- [x] **Step 2: Correrlos y ver que fallan**

Run: `php artisan test tests/Feature/Pwa/SyncVisitsExpensesOfflineTest.php`
Expected: FAIL. La conversión guarda 50 en vez de 50000. La atribución deja el gasto a nombre del admin. `una_visita_mala_no_tumba_el_lote` recibe 422.

- [x] **Step 3: Añadir `markPendingApproval()` a `Expense`**

Justo después de `applyApprovalPolicyForCreator()`:

```php
    /**
     * Deja el gasto pendiente de aprobación sin importar el rol de quien lo sube.
     * Lo usa la cola offline cuando el gasto lo capturó OTRO usuario en el mismo
     * teléfono: quien sube (aunque sea admin) no lo aprobó.
     */
    public function markPendingApproval(): void
    {
        $this->requires_approval = true;
        $this->approval_status = self::APPROVAL_PENDING;
        $this->approved_by_user_id = null;
        $this->approved_at = null;
        $this->preApproved = true;
    }
```

- [x] **Step 4: Reescribir `syncVisits` y `syncExpenses` en `SyncController`**

Añadir imports: `use App\Models\CompanyFinancialSettings;`, `use App\Models\User;`, `use App\Services\Sync\CapturerResolver;`, `use Illuminate\Support\Facades\Validator;`, `use Illuminate\Support\Str;`. Añadir `private readonly CapturerResolver $capturerResolver,` al constructor.

Reemplazar el método `syncVisits` completo:

```php
    /**
     * POST /api/pwa/sync/visits
     *
     * Visitas de cobranza offline en batch. Cada ítem se valida por separado (uno
     * malo ya no tumba el lote), la promesa escrita en modo simplificado se
     * convierte y la visita se atribuye a quien la capturó (PWA-001, PWA-010).
     */
    public function syncVisits(Request $request): JsonResponse
    {
        $request->validate([
            'visits' => ['required', 'array', 'max:100'],
            'visits.*' => ['array'],
        ]);

        $user = $request->user();
        $settings = CompanyFinancialSettings::forCompany($user->company_id);
        $visitsData = $request->visits;
        $results = [];
        $successCount = 0;
        $errorCount = 0;
        $duplicateCount = 0;

        /** @var array<int, \Illuminate\Support\Collection<int, int>> $accessByCapturer */
        $accessByCapturer = [];

        foreach ($visitsData as $visitData) {
            $idempotencyKey = $visitData['idempotency_key'] ?? null;

            try {
                if (! is_string($idempotencyKey) || ! Str::isUuid($idempotencyKey)) {
                    $results[] = ['idempotency_key' => $idempotencyKey, 'status' => 'error', 'error' => 'Clave de idempotencia inválida.'];
                    $errorCount++;

                    continue;
                }

                $existing = CollectionVisit::query()
                    ->where('company_id', $user->company_id)
                    ->where('idempotency_key', $idempotencyKey)
                    ->first();

                if ($existing) {
                    $results[] = [
                        'idempotency_key' => $idempotencyKey,
                        'status' => 'duplicate',
                        'visit_id' => $existing->id,
                        'message' => 'Visita ya registrada anteriormente.',
                    ];
                    $duplicateCount++;

                    continue;
                }

                $capturer = $this->capturerResolver->resolve($visitData['captured_by_user_id'] ?? null, $user);
                if (! $capturer) {
                    $results[] = ['idempotency_key' => $idempotencyKey, 'status' => 'error', 'error' => 'El usuario que registró la visita no pertenece a la empresa.'];
                    $errorCount++;

                    continue;
                }

                $visitData = $this->withRealAmount($visitData, 'promised_amount', $settings);

                $validator = Validator::make($visitData, [
                    'credit_id' => ['required', 'integer'],
                    'visit_type' => ['required', 'string', 'in:no_payment,follow_up,promise,other'],
                    'visit_date' => ['required', 'date'],
                    'installment_id' => ['nullable', 'integer'],
                    'reason' => ['nullable', 'string', 'max:255'],
                    'notes' => ['nullable', 'string', 'max:1000'],
                    'promised_date' => ['nullable', 'date'],
                    'promised_amount' => ['nullable', 'numeric', 'min:0'],
                    'visit_time' => ['nullable', 'string'],
                    'latitude' => ['nullable', 'numeric'],
                    'longitude' => ['nullable', 'numeric'],
                    'device_id' => ['nullable', 'string'],
                ]);

                if ($validator->fails()) {
                    $results[] = ['idempotency_key' => $idempotencyKey, 'status' => 'error', 'error' => $validator->errors()->first()];
                    $errorCount++;

                    continue;
                }

                // El acceso se mira contra quien CAPTURÓ la visita, no contra quien la sube.
                $accessByCapturer[$capturer->id] ??= $this->accessibleCreditIds($capturer);
                if (! $accessByCapturer[$capturer->id]->has((int) $visitData['credit_id'])) {
                    $results[] = ['idempotency_key' => $idempotencyKey, 'status' => 'error', 'error' => 'Crédito no encontrado o no accesible.'];
                    $errorCount++;

                    continue;
                }

                $visit = CollectionVisit::create([
                    'company_id' => $user->company_id,
                    'credit_id' => $visitData['credit_id'],
                    'installment_id' => $visitData['installment_id'] ?? null,
                    'collector_user_id' => $capturer->id,
                    'visit_type' => $visitData['visit_type'],
                    'reason' => $visitData['reason'] ?? null,
                    'notes' => $visitData['notes'] ?? null,
                    'promised_date' => $visitData['promised_date'] ?? null,
                    'promised_amount' => $visitData['promised_amount'] ?? null,
                    'visit_date' => $visitData['visit_date'],
                    'visit_time' => $visitData['visit_time'] ?? null,
                    'latitude' => $visitData['latitude'] ?? null,
                    'longitude' => $visitData['longitude'] ?? null,
                    'device_id' => $visitData['device_id'] ?? null,
                    'idempotency_key' => $idempotencyKey,
                ]);

                $results[] = [
                    'idempotency_key' => $idempotencyKey,
                    'status' => 'success',
                    'visit_id' => $visit->id,
                ];
                $successCount++;

            } catch (\Exception $e) {
                Log::error('PWA Sync: Visit error', [
                    'idempotency_key' => $idempotencyKey,
                    'error' => $e->getMessage(),
                    'user_id' => $user->id,
                ]);

                $results[] = [
                    'idempotency_key' => $idempotencyKey,
                    'status' => 'error',
                    'error' => 'Error interno al procesar la visita.',
                ];
                $errorCount++;
            }
        }

        return response()->json([
            'results' => $results,
            'summary' => [
                'total' => count($visitsData),
                'success' => $successCount,
                'duplicates' => $duplicateCount,
                'errors' => $errorCount,
            ],
            'synced_at' => now()->toIso8601String(),
        ]);
    }
```

Reemplazar el método `syncExpenses` completo:

```php
    /**
     * POST /api/pwa/sync/expenses
     *
     * Gastos offline en batch. Mismo patrón que visitas: validación por ítem,
     * conversión del modo simplificado y atribución al capturador. Un gasto
     * capturado por OTRO usuario del teléfono queda siempre pendiente de aprobación.
     *
     * Por ítem:
     *   success / duplicate → { idempotency_key, status: "ok",    expense_id }
     *   error               → { idempotency_key, status: "error", message }
     */
    public function syncExpenses(Request $request): JsonResponse
    {
        $request->validate([
            'expenses' => ['present', 'array', 'max:100'],
            'expenses.*' => ['array'],
        ]);

        $user = $request->user();
        $companyId = $user->company_id;
        $settings = CompanyFinancialSettings::forCompany($companyId);

        $expensesData = $request->expenses;
        $results = [];
        $processedCount = 0;
        $failedCount = 0;

        foreach ($expensesData as $expenseData) {
            $idempotencyKey = $expenseData['idempotency_key'] ?? null;

            try {
                if (! is_string($idempotencyKey) || ! Str::isUuid($idempotencyKey)) {
                    $results[] = ['idempotency_key' => $idempotencyKey, 'status' => 'error', 'message' => 'Clave de idempotencia inválida.'];
                    $failedCount++;

                    continue;
                }

                $existing = Expense::withoutGlobalScopes()
                    ->where('company_id', $companyId)
                    ->where('idempotency_key', $idempotencyKey)
                    ->first();

                if ($existing) {
                    $results[] = ['idempotency_key' => $idempotencyKey, 'status' => 'ok', 'expense_id' => $existing->id];
                    $processedCount++;

                    continue;
                }

                $capturer = $this->capturerResolver->resolve($expenseData['captured_by_user_id'] ?? null, $user);
                if (! $capturer) {
                    $results[] = ['idempotency_key' => $idempotencyKey, 'status' => 'error', 'message' => 'El usuario que registró el gasto no pertenece a la empresa.'];
                    $failedCount++;

                    continue;
                }

                $expenseData = $this->withRealAmount($expenseData, 'amount', $settings);

                $validator = Validator::make($expenseData, [
                    'amount' => ['required', 'numeric', 'min:0.01', 'max:9999999.99'],
                    'category' => ['required', 'string', 'in:'.implode(',', self::EXPENSE_ALLOWED_CATEGORIES)],
                    'notes' => ['nullable', 'string', 'max:1000'],
                    'date' => ['nullable', 'date'],
                ]);

                if ($validator->fails()) {
                    $results[] = ['idempotency_key' => $idempotencyKey, 'status' => 'error', 'message' => $validator->errors()->first()];
                    $failedCount++;

                    continue;
                }

                $opDate = ! empty($expenseData['date'])
                    ? Carbon::parse($expenseData['date'])->toDateString()
                    : Carbon::today()->toDateString();

                // Cada gasto en su propia transacción: un fallo no cancela el resto del batch.
                $expense = DB::transaction(function () use ($expenseData, $user, $capturer, $companyId, $idempotencyKey, $opDate) {
                    $expense = $this->financeLogger->logExpense([
                        'company_id' => $companyId,
                        'user_id' => $capturer->id,
                        'amount' => (float) $expenseData['amount'],
                        'category' => $expenseData['category'],
                        'operation_date' => $opDate,
                        'notes' => $expenseData['notes'] ?? null,
                        'idempotency_key' => $idempotencyKey,
                    ]);

                    if ($capturer->id === $user->id) {
                        // Mismo flujo que ExpenseController::store(): admin → aprobado; resto → pendiente.
                        $expense->applyApprovalPolicyForCreator($user);
                    } else {
                        $expense->markPendingApproval();
                    }
                    $expense->save();

                    return $expense;
                });

                $results[] = ['idempotency_key' => $idempotencyKey, 'status' => 'ok', 'expense_id' => $expense->id];
                $processedCount++;

                Log::info('PWA Sync: Expense synced', [
                    'expense_id' => $expense->id,
                    'idempotency_key' => $idempotencyKey,
                    'amount' => $expenseData['amount'],
                    'user_id' => $user->id,
                    'captured_by_user_id' => $capturer->id,
                ]);

            } catch (\Exception $e) {
                Log::error('PWA Sync: Expense error', [
                    'idempotency_key' => $idempotencyKey,
                    'error' => $e->getMessage(),
                    'user_id' => $user->id,
                ]);

                $results[] = [
                    'idempotency_key' => $idempotencyKey,
                    'status' => 'error',
                    'message' => 'Error interno al procesar el gasto.',
                ];
                $failedCount++;
            }
        }

        return response()->json([
            'processed' => $processedCount,
            'failed' => $failedCount,
            'results' => $results,
            'synced_at' => now()->toIso8601String(),
        ]);
    }

    /**
     * Pasa a monto real un valor que el cobrador escribió en modo simplificado
     * ('50' = $50.000). Se convierte si el ítem dice que se capturó así, aunque la
     * empresa haya apagado el modo después: el flag describe cómo se escribió.
     *
     * @param  array<string, mixed>  $item
     * @return array<string, mixed>
     */
    private function withRealAmount(array $item, string $field, CompanyFinancialSettings $settings): array
    {
        $capturedSimplified = filter_var($item['is_simplified_amount'] ?? false, FILTER_VALIDATE_BOOLEAN);

        if ($capturedSimplified && isset($item[$field]) && is_numeric($item[$field])) {
            $item[$field] = $settings->convertSimplifiedToReal((float) $item[$field]);
        }

        return $item;
    }

    /**
     * Créditos activos sobre los que un usuario puede registrar visitas.
     *
     * @return \Illuminate\Support\Collection<int, int>  ids como claves (para has())
     */
    private function accessibleCreditIds(User $user): \Illuminate\Support\Collection
    {
        $query = Credit::query()
            ->where('company_id', $user->company_id)
            ->whereIn('status', Credit::ACTIVE_STATUSES);

        if ($this->pwaRoleFor($user) === 'collector') {
            $query->where('collector_user_id', $user->id);
        }

        return $query->pluck('id')->flip();
    }
```

- [x] **Step 5: Ajustar `SyncExpensesTest::batch_with_one_invalid_expense_fails_that_one_but_processes_the_rest`**

Reemplazar el cuerpo de las líneas 155-164 por:

```php
        // La validación es por ítem: el inválido falla solo, los otros dos se guardan.
        $response = $this->actingAs($this->collector, 'sanctum')
            ->postJson('/api/pwa/sync/expenses', ['expenses' => [$validA, $invalid, $validB]]);

        $response->assertOk()
            ->assertJsonPath('processed', 2)
            ->assertJsonPath('failed', 1)
            ->assertJsonPath('results.1.status', 'error');

        $this->assertDatabaseHas('expenses', ['idempotency_key' => $validA['idempotency_key']]);
        $this->assertDatabaseHas('expenses', ['idempotency_key' => $validB['idempotency_key']]);
        $this->assertDatabaseMissing('expenses', ['idempotency_key' => $invalid['idempotency_key']]);
```

Actualizar la línea 24 del docblock de la clase a "Batch mixto (1 inválido) → el inválido falla solo, los válidos se procesan".

- [x] **Step 6: Correr los tests**

Run: `php artisan test tests/Feature/Pwa/SyncVisitsExpensesOfflineTest.php tests/Feature/SyncExpensesTest.php tests/Feature/ExpenseApprovalCashGatingTest.php`
Expected: PASS.

- [x] **Step 7: Commit**

```bash
git add app/Http/Controllers/Api/Pwa/SyncController.php app/Models/Expense.php tests/Feature/Pwa/SyncVisitsExpensesOfflineTest.php tests/Feature/SyncExpensesTest.php
git commit -F /home/jredondo/commit-msg.txt   # "fix(sync): visitas y gastos por ítem, montos simplificados y capturador (PWA-010)"
```

---

### Task 5: Aprobar o rechazar un cobro retenido

**Files:**
- Create: `app/Services/Payments/HeldPaymentReviewException.php`
- Create: `app/Services/Payments/HeldPaymentReviewService.php`
- Test: `tests/Feature/HeldPaymentReviewTest.php`

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature;

use App\Models\Client;
use App\Models\Company;
use App\Models\Credit;
use App\Models\HeldPayment;
use App\Models\Installment;
use App\Models\Payment;
use App\Models\User;
use App\Services\Payments\HeldPaymentReviewException;
use App\Services\Payments\HeldPaymentReviewService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Str;
use PHPUnit\Framework\Attributes\Test;
use Spatie\Permission\Models\Role;
use Tests\TestCase;

class HeldPaymentReviewTest extends TestCase
{
    use DatabaseTransactions;

    private Company $company;

    private User $collector;

    private User $admin;

    private Credit $credit;

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

        foreach (['admin', 'collector'] as $role) {
            Role::firstOrCreate(['name' => $role, 'guard_name' => 'web']);
        }

        $this->company = Company::factory()->create();
        $this->collector = User::factory()->create(['company_id' => $this->company->id]);
        $this->collector->assignRole('collector');
        $this->admin = User::factory()->create(['company_id' => $this->company->id]);
        $this->admin->assignRole('admin');

        $client = Client::factory()->create(['company_id' => $this->company->id]);
        $this->credit = Credit::factory()->create([
            'company_id' => $this->company->id,
            'client_id' => $client->id,
            'collector_user_id' => $this->collector->id,
            'created_by_user_id' => $this->collector->id,
            'status' => Credit::STATUS_ACTIVE,
            'amount' => 60_000,
            'installments_count' => 1,
            'periodicity' => 'monthly',
            'start_date' => now()->subDays(40),
            'due_date' => now()->addDays(5),
        ]);
        Installment::factory()->create([
            'company_id' => $this->company->id,
            'credit_id' => $this->credit->id,
            'installment_number' => 1,
            'status' => 'pending',
            'amount_paid' => 0,
            'total_amount' => 60_000,
            'principal_amount' => 48_000,
            'interest_amount' => 12_000,
            'due_date' => now()->addDays(5),
        ]);

        $this->actingAs($this->admin);
    }

    private function held(array $overrides = []): HeldPayment
    {
        return HeldPayment::create(array_merge([
            'company_id' => $this->company->id,
            'idempotency_key' => (string) Str::uuid(),
            'credit_id' => $this->credit->id,
            'captured_by_user_id' => $this->collector->id,
            'synced_by_user_id' => $this->collector->id,
            'amount' => 10_000,
            'payment_method' => 'cash',
            'payment_date' => now()->subDays(20)->toDateString(),
            'offline_created_at' => now()->subDays(20),
            'reason' => HeldPayment::REASON_STALE,
            'payload' => [],
            'status' => HeldPayment::STATUS_PENDING,
        ], $overrides));
    }

    #[Test]
    public function aprobar_registra_el_pago_con_la_fecha_la_clave_y_el_cobrador_originales(): void
    {
        $held = $this->held();

        $payment = app(HeldPaymentReviewService::class)->approve($held, $this->admin);

        $this->assertSame($held->idempotency_key, $payment->idempotency_key);
        $this->assertSame($this->collector->id, $payment->registered_by_user_id);
        $this->assertSame(now()->subDays(20)->toDateString(), $payment->payment_date->toDateString());

        $held->refresh();
        $this->assertSame(HeldPayment::STATUS_APPROVED, $held->status);
        $this->assertSame($payment->id, $held->payment_id);
        $this->assertSame($this->admin->id, $held->resolved_by_user_id);
        $this->assertNotNull($held->resolved_at);
    }

    #[Test]
    public function aprobar_dos_veces_no_crea_dos_pagos(): void
    {
        $held = $this->held();
        $service = app(HeldPaymentReviewService::class);

        $service->approve($held, $this->admin);

        $this->expectException(HeldPaymentReviewException::class);
        try {
            $service->approve($held->fresh(), $this->admin);
        } finally {
            $this->assertSame(1, Payment::withoutGlobalScopes()->where('idempotency_key', $held->idempotency_key)->count());
        }
    }

    #[Test]
    public function no_se_aprueba_si_el_credito_ya_no_admite_pagos(): void
    {
        $this->credit->update(['status' => Credit::STATUS_PAID]);
        $held = $this->held();
        $service = app(HeldPaymentReviewService::class);

        $this->assertNotNull($service->approvalBlocker($held));

        $this->expectException(HeldPaymentReviewException::class);
        $service->approve($held, $this->admin);
    }

    #[Test]
    public function no_se_aprueba_un_retenido_ilegible(): void
    {
        $held = $this->held(['reason' => HeldPayment::REASON_INVALID, 'amount' => null]);

        $this->assertNotNull(app(HeldPaymentReviewService::class)->approvalBlocker($held));
    }

    #[Test]
    public function no_se_aprueba_si_ya_existe_un_pago_con_la_misma_clave(): void
    {
        // Carrera entre pestañas: una aplicó el cobro mientras la otra lo mandaba a revisión.
        $held = $this->held();
        app(\App\Services\PaymentSyncService::class)->applyToCredit($this->credit, [
            'amount' => 10_000, 'payment_date' => now()->toDateString(), 'payment_method' => 'cash',
            'device_id' => null, 'offline_created_at' => null, 'latitude' => null, 'longitude' => null,
        ], $this->collector->id, $held->idempotency_key);

        $this->assertStringContainsString('ya está aplicado', (string) app(HeldPaymentReviewService::class)->approvalBlocker($held));
    }

    #[Test]
    public function no_se_aprueba_si_el_credito_no_tiene_cuotas_pendientes(): void
    {
        Installment::query()->where('credit_id', $this->credit->id)->update(['status' => 'paid']);

        $this->assertNotNull(app(HeldPaymentReviewService::class)->approvalBlocker($this->held()));
    }

    #[Test]
    public function no_se_aprueba_si_el_capturador_ya_no_existe(): void
    {
        $held = $this->held();
        $this->collector->delete();

        $this->assertNull($held->fresh()->captured_by_user_id, 'la FK del capturador queda en null al borrar el usuario');
        $this->assertNotNull(app(HeldPaymentReviewService::class)->approvalBlocker($held->fresh()));
    }

    #[Test]
    public function rechazar_exige_motivo_y_no_crea_pago(): void
    {
        $held = $this->held();
        $service = app(HeldPaymentReviewService::class);

        try {
            $service->reject($held, $this->admin, '   ');
            $this->fail('Debió exigir el motivo');
        } catch (HeldPaymentReviewException) {
            // esperado
        }

        $service->reject($held, $this->admin, 'Ya se había cargado a mano (pago #123)');

        $held->refresh();
        $this->assertSame(HeldPayment::STATUS_REJECTED, $held->status);
        $this->assertSame('Ya se había cargado a mano (pago #123)', $held->resolution_notes);
        $this->assertDatabaseMissing('payments', ['idempotency_key' => $held->idempotency_key]);
    }

    #[Test]
    public function los_posibles_duplicados_son_pagos_del_mismo_credito_y_monto_cerca_de_la_fecha(): void
    {
        $held = $this->held(['payment_date' => now()->subDays(20)->toDateString()]);

        $cerca = app(\App\Services\PaymentSyncService::class)->applyToCredit($this->credit, [
            'amount' => 10_000, 'payment_date' => now()->subDays(18)->toDateString(), 'payment_method' => 'cash',
            'device_id' => null, 'offline_created_at' => null, 'latitude' => null, 'longitude' => null,
        ], $this->collector->id, (string) Str::uuid());

        // Otro monto: no es duplicado.
        app(\App\Services\PaymentSyncService::class)->applyToCredit($this->credit->fresh(), [
            'amount' => 5_000, 'payment_date' => now()->subDays(19)->toDateString(), 'payment_method' => 'cash',
            'device_id' => null, 'offline_created_at' => null, 'latitude' => null, 'longitude' => null,
        ], $this->collector->id, (string) Str::uuid());

        // Mismo monto pero anulado: el original anulado y su reversa no cuentan.
        $anulado = app(\App\Services\PaymentSyncService::class)->applyToCredit($this->credit->fresh(), [
            'amount' => 10_000, 'payment_date' => now()->subDays(21)->toDateString(), 'payment_method' => 'cash',
            'device_id' => null, 'offline_created_at' => null, 'latitude' => null, 'longitude' => null,
        ], $this->collector->id, (string) Str::uuid());
        app(\App\Services\PaymentManager::class)->reversePayment($anulado, $this->admin, 'Prueba de duplicados');

        $this->assertSame([$cerca->id], $held->possibleDuplicates()->pluck('id')->all());
    }
}
```

- [x] **Step 2: Correrlos y ver que fallan**

Run: `php artisan test tests/Feature/HeldPaymentReviewTest.php`
Expected: FAIL — `Class "App\Services\Payments\HeldPaymentReviewService" not found` (el de duplicados ya pasaría: usa el modelo de la Task 1).

- [x] **Step 3: Crear la excepción**

```php
<?php

declare(strict_types=1);

namespace App\Services\Payments;

/** Un cobro retenido que no se puede aprobar o rechazar; el mensaje es para el admin. */
class HeldPaymentReviewException extends \RuntimeException {}
```

- [x] **Step 4: Crear el servicio**

```php
<?php

declare(strict_types=1);

namespace App\Services\Payments;

use App\Models\Credit;
use App\Models\HeldPayment;
use App\Models\Payment;
use App\Models\User;
use App\Services\PaymentSyncService;
use Illuminate\Support\Facades\DB;

/**
 * Resolución de un cobro retenido por la cola offline.
 *
 * Aprobar aplica el cobro tal como se capturó (fecha, clave, cobrador) por el
 * mismo camino que el lote. Rechazar lo cierra con un motivo, sin tocar saldos.
 * Las dos cosas bloquean la fila: dos admins no pueden resolver el mismo retenido.
 */
class HeldPaymentReviewService
{
    public function __construct(private readonly PaymentSyncService $paymentSyncService) {}

    /** Por qué no se puede aprobar, o null si se puede. */
    public function approvalBlocker(HeldPayment $held): ?string
    {
        if ($held->status !== HeldPayment::STATUS_PENDING) {
            return 'Este cobro ya fue revisado.';
        }

        if ($held->reason === HeldPayment::REASON_INVALID || $held->amount === null || $held->payment_date === null) {
            return 'Los datos del cobro llegaron incompletos o ilegibles: no se puede aplicar.';
        }

        // Si se borró el usuario, la FK queda en null: no hay a nombre de quién registrarlo.
        if ($held->captured_by_user_id === null) {
            return 'El usuario que capturó el cobro ya no existe: no se puede registrar a su nombre. Regístralo a mano y rechaza este.';
        }

        // payments.idempotency_key es único en toda la tabla: si ya existe, aprobar
        // chocaría con el índice. Pasa si otra pestaña aplicó el mismo cobro.
        $applied = Payment::query()->withoutGlobalScopes()->where('idempotency_key', $held->idempotency_key)->value('id');
        if ($applied !== null) {
            return "Este cobro ya está aplicado (pago #{$applied}).";
        }

        // Las mismas reglas que usa el lote para decidir si aplicar: una sola fuente.
        $credit = $this->creditFor($held);
        $blocker = $this->paymentSyncService->applicationBlocker($credit, (float) $held->amount);
        if ($blocker !== null) {
            return $blocker[0] === HeldPayment::REASON_CREDIT_UNAVAILABLE
                ? $blocker[1].' Si corresponde, regístralo a mano en el crédito vigente y rechaza este.'
                : $blocker[1];
        }

        return null;
    }

    public function approve(HeldPayment $held, User $reviewer): Payment
    {
        return DB::transaction(function () use ($held, $reviewer) {
            $locked = $this->lock($held);

            $blocker = $this->approvalBlocker($locked);
            if ($blocker !== null) {
                throw new HeldPaymentReviewException($blocker);
            }

            /** @var Credit $credit  approvalBlocker() ya garantizó que existe */
            $credit = $this->creditFor($locked);
            /** @var \Illuminate\Support\Carbon $paymentDate */
            $paymentDate = $locked->payment_date;

            $payment = $this->paymentSyncService->applyToCredit($credit, [
                'amount' => (float) $locked->amount,
                'payment_date' => $paymentDate->toDateString(),
                'payment_method' => $locked->payment_method,
                'device_id' => $locked->device_id,
                'offline_created_at' => $locked->offline_created_at,
                'latitude' => $locked->latitude,
                'longitude' => $locked->longitude,
            ], (int) $locked->captured_by_user_id, $locked->idempotency_key, rethrowCloseFailure: true);
            // Dentro de esta transacción un fallo al cerrar el crédito debe subir:
            // si MySQL revirtió todo, no se puede marcar el retenido como aprobado.

            $locked->update([
                'status' => HeldPayment::STATUS_APPROVED,
                'resolved_by_user_id' => $reviewer->id,
                'resolved_at' => now(),
                'payment_id' => $payment->id,
            ]);

            return $payment;
        });
    }

    public function reject(HeldPayment $held, User $reviewer, string $notes): void
    {
        $notes = trim($notes);
        if ($notes === '') {
            throw new HeldPaymentReviewException('El motivo del rechazo es obligatorio.');
        }

        DB::transaction(function () use ($held, $reviewer, $notes) {
            $locked = $this->lock($held);

            if ($locked->status !== HeldPayment::STATUS_PENDING) {
                throw new HeldPaymentReviewException('Este cobro ya fue revisado.');
            }

            $locked->update([
                'status' => HeldPayment::STATUS_REJECTED,
                'resolved_by_user_id' => $reviewer->id,
                'resolved_at' => now(),
                'resolution_notes' => $notes,
            ]);
        });
    }

    private function lock(HeldPayment $held): HeldPayment
    {
        /** @var HeldPayment */
        return HeldPayment::query()
            ->withoutGlobalScopes()
            ->whereKey($held->id)
            ->lockForUpdate()
            ->firstOrFail();
    }

    private function creditFor(HeldPayment $held): ?Credit
    {
        if ($held->credit_id === null) {
            return null;
        }

        return Credit::query()
            ->withoutGlobalScopes()
            ->where('company_id', $held->company_id)
            ->activeLeafCredits()
            ->find($held->credit_id);
    }
}
```

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

Run: `php artisan test tests/Feature/HeldPaymentReviewTest.php`
Expected: PASS.

- [x] **Step 6: Commit**

```bash
git add app/Services/Payments/HeldPaymentReviewService.php app/Services/Payments/HeldPaymentReviewException.php tests/Feature/HeldPaymentReviewTest.php
git commit -F /home/jredondo/commit-msg.txt   # "feat(sync): aprobar o rechazar un cobro retenido"
```

---

### Task 6: "Cobros en revisión" en el panel

**Files:**
- Create: `app/Policies/HeldPaymentPolicy.php`
- Create: `app/Filament/Resources/HeldPayments/HeldPaymentResource.php`
- Create: `app/Filament/Resources/HeldPayments/Pages/ListHeldPayments.php`
- Create: `app/Filament/Resources/HeldPayments/Pages/ViewHeldPayment.php`
- Create: `app/Filament/Resources/HeldPayments/Tables/HeldPaymentsTable.php`
- Test: `tests/Feature/Filament/HeldPaymentResourceTest.php`

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Filament;

use App\Filament\Resources\HeldPayments\Pages\ListHeldPayments;
use App\Filament\Resources\HeldPayments\Pages\ViewHeldPayment;
use App\Models\Client;
use App\Models\Company;
use App\Models\Credit;
use App\Models\HeldPayment;
use App\Models\Installment;
use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Str;
use Livewire\Livewire;
use PHPUnit\Framework\Attributes\Test;
use Spatie\Permission\Models\Role;
use Tests\TestCase;

class HeldPaymentResourceTest extends TestCase
{
    use DatabaseTransactions;

    private Company $company;

    private User $admin;

    private User $collector;

    private HeldPayment $held;

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

        foreach (['super_admin', 'admin', 'supervisor', 'collector'] as $role) {
            Role::firstOrCreate(['name' => $role, 'guard_name' => 'web']);
        }

        $this->company = Company::factory()->create();
        $this->admin = User::factory()->create(['company_id' => $this->company->id]);
        $this->admin->assignRole('admin');
        $this->collector = User::factory()->create(['company_id' => $this->company->id]);
        $this->collector->assignRole('collector');

        $client = Client::factory()->create(['company_id' => $this->company->id]);
        $credit = Credit::factory()->create([
            'company_id' => $this->company->id,
            'client_id' => $client->id,
            'collector_user_id' => $this->collector->id,
            'created_by_user_id' => $this->collector->id,
            'status' => Credit::STATUS_ACTIVE,
            'amount' => 60_000,
            'installments_count' => 1,
            'periodicity' => 'monthly',
            'start_date' => now()->subDays(40),
            'due_date' => now()->addDays(5),
        ]);
        Installment::factory()->create([
            'company_id' => $this->company->id,
            'credit_id' => $credit->id,
            'installment_number' => 1,
            'status' => 'pending',
            'amount_paid' => 0,
            'total_amount' => 60_000,
            'principal_amount' => 48_000,
            'interest_amount' => 12_000,
            'due_date' => now()->addDays(5),
        ]);

        $this->held = HeldPayment::create([
            'company_id' => $this->company->id,
            'idempotency_key' => (string) Str::uuid(),
            'credit_id' => $credit->id,
            'captured_by_user_id' => $this->collector->id,
            'synced_by_user_id' => $this->collector->id,
            'amount' => 10_000,
            'payment_method' => 'cash',
            'payment_date' => now()->subDays(20)->toDateString(),
            'reason' => HeldPayment::REASON_STALE,
            'payload' => [],
            'status' => HeldPayment::STATUS_PENDING,
        ]);
    }

    #[Test]
    public function solo_admin_y_super_admin_revisan_cobros_retenidos(): void
    {
        $supervisor = User::factory()->create(['company_id' => $this->company->id]);
        $supervisor->assignRole('supervisor');
        $otraEmpresa = User::factory()->create(['company_id' => Company::factory()->create()->id]);
        $otraEmpresa->assignRole('admin');

        $this->assertTrue($this->admin->can('viewAny', HeldPayment::class));
        $this->assertTrue($this->admin->can('approve', $this->held));
        $this->assertFalse($this->collector->can('viewAny', HeldPayment::class));
        $this->assertFalse($supervisor->can('viewAny', HeldPayment::class));
        $this->assertFalse($otraEmpresa->can('view', $this->held));
        $this->assertFalse($this->admin->can('create', HeldPayment::class));
        $this->assertFalse($this->admin->can('delete', $this->held));
    }

    #[Test]
    public function el_admin_ve_la_bandeja_y_aprueba_desde_la_vista(): void
    {
        $this->actingAs($this->admin);
        Filament::setCurrentPanel(Filament::getDefaultPanel());

        Livewire::test(ListHeldPayments::class)
            ->assertOk()
            ->assertCanSeeTableRecords([$this->held]);

        Livewire::test(ViewHeldPayment::class, ['record' => $this->held->getRouteKey()])
            ->assertOk()
            ->callAction('approve');

        $this->assertSame(HeldPayment::STATUS_APPROVED, $this->held->fresh()->status);
        $this->assertDatabaseHas('payments', ['idempotency_key' => $this->held->idempotency_key]);
    }
}
```

- [x] **Step 2: Correrlos y ver que fallan**

Run: `php artisan test tests/Feature/Filament/HeldPaymentResourceTest.php`
Expected: FAIL. Sin policy, `can('viewAny')` es `false` para todos (el admin falla), y `ListHeldPayments` no existe.

- [x] **Step 3: Crear la policy**

```php
<?php

declare(strict_types=1);

namespace App\Policies;

use App\Models\HeldPayment;
use App\Models\User;

/**
 * Cobros retenidos por la cola offline: los revisa el admin de la empresa (y el
 * super_admin). Declara TODAS las habilidades a propósito: una policy con solo
 * `before()` no restringe nada, y Filament consulta cada una por separado.
 * Crear, editar y borrar no existen: un retenido solo se aprueba o se rechaza.
 */
class HeldPaymentPolicy
{
    private function isReviewer(User $user): bool
    {
        return $user->hasAnyRole(['admin', 'super_admin']);
    }

    private function canReview(User $user, HeldPayment $held): bool
    {
        return $this->isReviewer($user)
            && ($user->isSuperAdmin() || $user->company_id === $held->company_id);
    }

    public function viewAny(User $user): bool
    {
        return $this->isReviewer($user);
    }

    public function view(User $user, HeldPayment $held): bool
    {
        return $this->canReview($user, $held);
    }

    public function approve(User $user, HeldPayment $held): bool
    {
        return $this->canReview($user, $held);
    }

    public function reject(User $user, HeldPayment $held): bool
    {
        return $this->canReview($user, $held);
    }

    public function create(User $user): bool
    {
        return false;
    }

    public function update(User $user, HeldPayment $held): bool
    {
        return false;
    }

    public function delete(User $user, HeldPayment $held): bool
    {
        return false;
    }

    public function deleteAny(User $user): bool
    {
        return false;
    }

    public function restore(User $user, HeldPayment $held): bool
    {
        return false;
    }

    public function restoreAny(User $user): bool
    {
        return false;
    }

    public function forceDelete(User $user, HeldPayment $held): bool
    {
        return false;
    }

    public function forceDeleteAny(User $user): bool
    {
        return false;
    }

    public function replicate(User $user, HeldPayment $held): bool
    {
        return false;
    }

    public function reorder(User $user): bool
    {
        return false;
    }
}
```

- [x] **Step 4: Crear el recurso**

```php
<?php

declare(strict_types=1);

namespace App\Filament\Resources\HeldPayments;

use App\Filament\Resources\HeldPayments\Pages\ListHeldPayments;
use App\Filament\Resources\HeldPayments\Pages\ViewHeldPayment;
use App\Filament\Resources\HeldPayments\Tables\HeldPaymentsTable;
use App\Models\HeldPayment;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use UnitEnum;

/**
 * Cobros que la cola offline no pudo aplicar con seguridad (PWA-001): llegaron
 * tarde, apuntan a un crédito que cambió, superan el saldo o los capturó otro
 * usuario del mismo teléfono. El admin los aprueba o los rechaza aquí.
 */
class HeldPaymentResource extends Resource
{
    protected static ?string $model = HeldPayment::class;

    protected static ?string $modelLabel = 'Cobro en revisión';

    protected static ?string $pluralModelLabel = 'Cobros en revisión';

    protected static ?string $navigationLabel = 'Cobros en revisión';

    protected static string|UnitEnum|null $navigationGroup = 'Gestión de Créditos';

    protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-inbox-stack';

    protected static ?int $navigationSort = 25;

    public static function getNavigationBadge(): ?string
    {
        $count = static::getEloquentQuery()->pending()->count();

        return $count > 0 ? (string) $count : null;
    }

    public static function getNavigationBadgeColor(): ?string
    {
        return 'warning';
    }

    public static function table(Table $table): Table
    {
        return HeldPaymentsTable::configure($table);
    }

    public static function getPages(): array
    {
        return [
            'index' => ListHeldPayments::route('/'),
            'view' => ViewHeldPayment::route('/{record}'),
        ];
    }

    public static function getEloquentQuery(): Builder
    {
        $query = parent::getEloquentQuery()->with(['capturedBy', 'syncedBy', 'credit.client']);
        $user = Auth::user();

        if ($user && ! $user->isSuperAdmin()) {
            $query->where('company_id', $user->company_id);
        }

        return $query;
    }
}
```

- [x] **Step 5: Crear la tabla**

```php
<?php

declare(strict_types=1);

namespace App\Filament\Resources\HeldPayments\Tables;

use App\Filament\Resources\HeldPayments\HeldPaymentResource;
use App\Models\HeldPayment;
use App\Support\Format;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;

class HeldPaymentsTable
{
    public static function configure(Table $table): Table
    {
        return $table
            ->columns([
                // Las FKs de usuario quedan en null si se borra el usuario.
                TextColumn::make('capturedBy.name')
                    ->label('Cobrador')
                    ->placeholder('Usuario eliminado')
                    ->searchable(),
                TextColumn::make('credit.client.name')
                    ->label('Cliente')
                    ->placeholder('Crédito no disponible')
                    ->searchable(),
                TextColumn::make('amount')
                    ->label('Monto')
                    ->formatStateUsing(fn ($state) => $state === null ? '—' : Format::money($state)),
                TextColumn::make('payment_date')
                    ->label('Fecha de cobro')
                    ->date('d/m/Y')
                    ->placeholder('—')
                    ->sortable(),
                TextColumn::make('offline_created_at')
                    ->label('Capturado')
                    ->since()
                    ->placeholder('—'),
                TextColumn::make('reason')
                    ->label('Motivo')
                    ->badge()
                    ->formatStateUsing(fn (string $state): string => HeldPayment::reasonLabel($state)),
                TextColumn::make('status')
                    ->label('Estado')
                    ->badge()
                    ->formatStateUsing(fn (string $state): string => HeldPayment::statusLabel($state))
                    ->color(fn (string $state): string => match ($state) {
                        HeldPayment::STATUS_PENDING => 'warning',
                        HeldPayment::STATUS_APPROVED => 'success',
                        default => 'danger',
                    }),
            ])
            ->filters([
                SelectFilter::make('status')
                    ->label('Estado')
                    ->options([
                        HeldPayment::STATUS_PENDING => HeldPayment::statusLabel(HeldPayment::STATUS_PENDING),
                        HeldPayment::STATUS_APPROVED => HeldPayment::statusLabel(HeldPayment::STATUS_APPROVED),
                        HeldPayment::STATUS_REJECTED => HeldPayment::statusLabel(HeldPayment::STATUS_REJECTED),
                    ])
                    ->default(HeldPayment::STATUS_PENDING),
            ])
            ->defaultSort('created_at', 'desc')
            ->recordUrl(fn (HeldPayment $record) => HeldPaymentResource::getUrl('view', ['record' => $record]));
    }
}
```

- [x] **Step 6: Crear las páginas**

`Pages/ListHeldPayments.php`:

```php
<?php

declare(strict_types=1);

namespace App\Filament\Resources\HeldPayments\Pages;

use App\Filament\Resources\HeldPayments\HeldPaymentResource;
use Filament\Resources\Pages\ListRecords;

class ListHeldPayments extends ListRecords
{
    protected static string $resource = HeldPaymentResource::class;
}
```

`Pages/ViewHeldPayment.php`:

```php
<?php

declare(strict_types=1);

namespace App\Filament\Resources\HeldPayments\Pages;

use App\Filament\Resources\HeldPayments\HeldPaymentResource;
use App\Models\HeldPayment;
use App\Models\Payment;
use App\Services\Payments\HeldPaymentReviewException;
use App\Services\Payments\HeldPaymentReviewService;
use App\Support\Format;
use App\Support\SafeReport;
use Filament\Actions\Action;
use Filament\Forms\Components\Textarea;
use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Illuminate\Support\Facades\Auth;

class ViewHeldPayment extends ViewRecord
{
    protected static string $resource = HeldPaymentResource::class;

    private function held(): HeldPayment
    {
        $record = $this->getRecord();
        assert($record instanceof HeldPayment);

        return $record;
    }

    private function review(): HeldPaymentReviewService
    {
        return app(HeldPaymentReviewService::class);
    }

    public function infolist(Schema $schema): Schema
    {
        return $schema
            ->columns(1)
            ->components([
                Section::make('Cobro')
                    ->columns(3)
                    ->schema([
                        TextEntry::make('capturedBy.name')->label('Cobrador')->placeholder('Usuario eliminado'),
                        TextEntry::make('credit.client.name')->label('Cliente')->placeholder('Crédito no disponible'),
                        TextEntry::make('credit_id')->label('Crédito')->placeholder('—'),
                        TextEntry::make('amount')->label('Monto')
                            ->formatStateUsing(fn ($state) => $state === null ? '—' : Format::money($state)),
                        TextEntry::make('payment_method')->label('Método')->placeholder('—'),
                        TextEntry::make('payment_date')->label('Fecha de cobro')->date('d/m/Y')->placeholder('—'),
                        TextEntry::make('offline_created_at')->label('Capturado en el teléfono')->dateTime('d/m/Y H:i')->placeholder('—'),
                        TextEntry::make('created_at')->label('Llegó al servidor')->dateTime('d/m/Y H:i'),
                        TextEntry::make('syncedBy.name')->label('Subido por')->placeholder('Usuario eliminado'),
                    ]),

                Section::make('Por qué quedó retenido')
                    ->schema([
                        TextEntry::make('reason')->label('Motivo')->badge()
                            ->formatStateUsing(fn (string $state): string => HeldPayment::reasonLabel($state)),
                        TextEntry::make('reason_detail')->label('Detalle')->placeholder('—'),
                        TextEntry::make('se_puede_aprobar')->label('¿Se puede aprobar?')
                            ->state(fn (HeldPayment $record): string => $this->review()->approvalBlocker($record) ?? 'Sí: el crédito admite este pago.')
                            ->visible(fn (HeldPayment $record): bool => $record->status === HeldPayment::STATUS_PENDING),
                    ]),

                Section::make('Posibles duplicados')
                    ->description('Pagos del mismo crédito, por el mismo monto, a '.HeldPayment::DUPLICATE_WINDOW_DAYS.' días o menos de la fecha de cobro. Si alguno es este mismo cobro cargado a mano, rechaza el retenido.')
                    ->schema([
                        TextEntry::make('duplicados')->hiddenLabel()
                            ->state(fn (HeldPayment $record): array => $record->possibleDuplicates()
                                ->map(fn (Payment $p): string => "Pago #{$p->id} · ".Format::money($p->amount).' · '.$p->payment_date->format('d/m/Y'))
                                ->whenEmpty(fn ($c) => $c->push('Ninguno'))
                                ->all())
                            ->listWithLineBreaks(),
                    ]),

                Section::make('Resolución')
                    ->columns(3)
                    ->visible(fn (HeldPayment $record): bool => $record->status !== HeldPayment::STATUS_PENDING)
                    ->schema([
                        TextEntry::make('status')->label('Estado')
                            ->formatStateUsing(fn (string $state): string => HeldPayment::statusLabel($state)),
                        TextEntry::make('resolvedBy.name')->label('Resuelto por')->placeholder('Usuario eliminado'),
                        TextEntry::make('resolved_at')->label('Fecha')->dateTime('d/m/Y H:i'),
                        TextEntry::make('payment_id')->label('Pago creado')->placeholder('—'),
                        TextEntry::make('resolution_notes')->label('Notas')->columnSpanFull()->placeholder('—'),
                    ]),
            ]);
    }

    protected function getHeaderActions(): array
    {
        return [
            Action::make('approve')
                ->label('Aprobar y aplicar')
                ->icon('heroicon-o-check-circle')
                ->color('success')
                ->requiresConfirmation()
                ->modalHeading('Aplicar este cobro al crédito')
                ->modalDescription('Se registra el pago con la fecha de cobro original, a nombre del cobrador que lo capturó. El dinero se aplica a la cuota pendiente más antigua de hoy, y el ingreso queda con la fecha original: si es de un período ya cerrado, sus cifras cambian.')
                ->authorize('approve')
                ->visible(fn (): bool => $this->review()->approvalBlocker($this->held()) === null)
                ->action(function (): void {
                    try {
                        $payment = $this->review()->approve($this->held(), Auth::user());
                    } catch (HeldPaymentReviewException $e) {
                        Notification::make()->title($e->getMessage())->danger()->send();

                        return;
                    } catch (\Throwable $e) {
                        // Carrera con otro pago sobre el mismo crédito, choque de clave, etc.:
                        // la transacción se revirtió y el retenido sigue pendiente.
                        // Sin datos del cobro: el mensaje de una QueryException trae el SQL con GPS y dispositivo.
                        SafeReport::report($e);
                        Notification::make()->title('No se pudo aplicar el cobro. Recarga la página e inténtalo de nuevo.')->danger()->send();

                        return;
                    }

                    Notification::make()->title("Cobro aplicado (pago #{$payment->id})")->success()->send();
                    $this->redirect(HeldPaymentResource::getUrl('view', ['record' => $this->held()]));
                }),

            Action::make('reject')
                ->label('Rechazar')
                ->icon('heroicon-o-x-circle')
                ->color('danger')
                ->authorize('reject')
                ->visible(fn (): bool => $this->held()->status === HeldPayment::STATUS_PENDING)
                ->form([
                    Textarea::make('resolution_notes')
                        ->label('Motivo del rechazo')
                        ->helperText('Por ejemplo: "ya se había cargado a mano como pago #123".')
                        ->required()
                        ->rows(3),
                ])
                ->action(function (array $data): void {
                    try {
                        $this->review()->reject($this->held(), Auth::user(), (string) $data['resolution_notes']);
                    } catch (HeldPaymentReviewException $e) {
                        Notification::make()->title($e->getMessage())->danger()->send();

                        return;
                    } catch (\Throwable $e) {
                        // Sin datos del cobro: el mensaje de una QueryException trae el SQL con GPS y dispositivo.
                        SafeReport::report($e);
                        Notification::make()->title('No se pudo rechazar el cobro. Recarga la página e inténtalo de nuevo.')->danger()->send();

                        return;
                    }

                    Notification::make()->title('Cobro rechazado')->warning()->send();
                    $this->redirect(HeldPaymentResource::getUrl('view', ['record' => $this->held()]));
                }),
        ];
    }
}
```

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

Run: `php artisan test tests/Feature/Filament/HeldPaymentResourceTest.php tests/Feature/Security/FilamentPanelAuthorizationTest.php`
Expected: PASS. Si `callAction` o `authorize()` tienen otra firma en esta versión de Filament 5, ajustar siguiendo el patrón de `ViewSubscriptionRequest` y documentarlo en el commit.

- [x] **Step 8: Commit**

```bash
git add app/Policies/HeldPaymentPolicy.php app/Filament/Resources/HeldPayments tests/Feature/Filament/HeldPaymentResourceTest.php
git commit -F /home/jredondo/commit-msg.txt   # "feat(panel): bandeja de cobros en revisión con aprobar y rechazar"
```

---

### Task 7: Locale con guion (la pantalla de errores se quedaba en blanco)

**Files:**
- Modify: `app/Http/Controllers/Api/Pwa/SettingsController.php:81`
- Modify: `resources/js/pwa/stores/settings.js:66,194`
- Test: `tests/Feature/Pwa/SettingsLocaleTest.php`

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Pwa;

use App\Models\Company;
use App\Models\Plan;
use App\Models\Subscription;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use PHPUnit\Framework\Attributes\Test;
use Spatie\Permission\Models\Role;
use Tests\TestCase;

/**
 * El locale de la empresa se guarda al estilo PHP ('es_CO'), pero la PWA lo pasa
 * a Intl.NumberFormat, que exige 'es-CO' y con guion bajo lanza RangeError: la
 * pantalla de errores de sincronización se quedaba en blanco. (PWA-008)
 */
class SettingsLocaleTest extends TestCase
{
    use DatabaseTransactions;

    #[Test]
    public function el_locale_sale_con_guion(): void
    {
        Role::firstOrCreate(['name' => 'collector', 'guard_name' => 'web']);
        $company = Company::factory()->create(['locale' => 'es_CO']);
        Subscription::factory()->create([
            'company_id' => $company->id,
            'plan_id' => Plan::factory()->create()->id,
            'status' => Subscription::STATUS_ACTIVE,
            'is_active' => true,
            'starts_at' => now()->subMonth(),
            'ends_at' => now()->addYear(),
        ]);
        $user = User::factory()->create(['company_id' => $company->id]);
        $user->assignRole('collector');

        $this->actingAs($user->fresh(), 'sanctum')
            ->getJson('/api/pwa/settings')
            ->assertOk()
            ->assertJsonPath('currency.locale', 'es-CO');
    }
}
```

- [x] **Step 2: Correrlo y ver que falla**

Run: `php artisan test tests/Feature/Pwa/SettingsLocaleTest.php`
Expected: FAIL — `'es_CO'` en vez de `'es-CO'`. Si falla porque `companies` no tiene columna `locale`, quitar `'locale' => 'es_CO'` del factory: el default del controlador ya cubre el caso.

- [x] **Step 3: Normalizar en el servidor (`SettingsController.php:81`)**

```php
                // Intl (la PWA) exige 'es-CO'; el formato PHP 'es_CO' lanza RangeError.
                'locale' => str_replace('_', '-', (string) ($user->company?->locale ?? 'es-CO')),
```

- [x] **Step 4: Normalizar también en la PWA (`settings.js`)**

Línea 66, el default: `locale: 'es-CO'`.

Línea 194, en `formatAmount`:

```js
        // Intl exige 'es-CO'. Con 'es_CO' (formato PHP, lo que venía del servidor y
        // lo que puede quedar en la caché local) lanza RangeError y la vista que
        // formatea se queda en blanco.
        return new Intl.NumberFormat(String(currency.value.locale || 'es-CO').replace('_', '-'), {
```

- [x] **Step 5: Correr el test**

Run: `php artisan test tests/Feature/Pwa/SettingsLocaleTest.php`
Expected: PASS.

- [x] **Step 6: Commit**

```bash
git add app/Http/Controllers/Api/Pwa/SettingsController.php resources/js/pwa/stores/settings.js tests/Feature/Pwa/SettingsLocaleTest.php
git commit -F /home/jredondo/commit-msg.txt   # "fix(pwa): locale con guion para Intl"
```

---

### Task 8: E2E que prueba la cola de punta a punta (se escribe primero y falla)

**Files:**
- Create: `tests/e2e/cola-offline.spec.js`
- Modify: `tests/e2e/sync-purge.spec.js:161-171`

- [x] **Step 1: Escribir el spec**

```js
import { test, expect } from '@playwright/test';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { randomUUID } from 'node:crypto';

const creds = JSON.parse(
    readFileSync(path.join(process.cwd(), 'tests/e2e/.auth/creds.json'), 'utf8')
);

const DB_NAME = 'CredifyGoPWA';

/**
 * IndexedDB con la API cruda: Dexie vive dentro del bundle y no está en `window`.
 * Mismo helper que sync-purge.spec.js.
 */
function idb(page, fn, arg) {
    return page.evaluate(
        ([body, dbName, payload]) => {
            // eslint-disable-next-line no-new-func
            const run = new Function('db', 'payload', `return (${body})(db, payload)`);

            return new Promise((resolve, reject) => {
                const req = indexedDB.open(dbName);
                req.onerror = () => reject(req.error);
                req.onsuccess = () => {
                    Promise.resolve(run(req.result, payload))
                        .then(resolve, reject)
                        .finally(() => req.result.close());
                };
            });
        },
        [fn.toString(), DB_NAME, arg ?? null]
    );
}

const readAll = (db, store) =>
    new Promise((resolve, reject) => {
        const req = db.transaction(store, 'readonly').objectStore(store).getAll();
        req.onsuccess = () => resolve(req.result);
        req.onerror = () => reject(req.error);
    });

const putRows = (db, { store, rows }) =>
    new Promise((resolve, reject) => {
        const tx = db.transaction(store, 'readwrite');
        for (const row of rows) tx.objectStore(store).put(row);
        tx.oncomplete = () => resolve(true);
        tx.onerror = () => reject(tx.error);
    });

/** Un crédito activo con cuota pendiente, leído de la caché que usa la app sin señal. */
const creditoConCuota = async (db) => {
    const leer = (s) =>
        new Promise((ok, mal) => {
            const r = db.transaction(s, 'readonly').objectStore(s).getAll();
            r.onsuccess = () => ok(r.result);
            r.onerror = () => mal(r.error);
        });
    const [creditos, cuotas] = await Promise.all([leer('credits'), leer('installments')]);
    return (
        creditos.find(
            (c) =>
                ['active', 'delayed', 'overdue'].includes(c.status) &&
                c.remaining_balance > 1000 &&
                cuotas.some((i) => i.credit_id === c.id && ['pending', 'partial_paid'].includes(i.status))
        ) ?? null
    );
};

/**
 * OJO: después del login NADA puede recargar la página. El token vive solo en
 * memoria; toda la navegación es dentro de la SPA.
 */
async function loginAsCollector(page) {
    const cred = creds.find((c) => c.role === 'collector');
    expect(cred, 'sin credencial para el rol collector').toBeTruthy();

    await page.goto('/pwa/login');
    await page.getByPlaceholder('Correo electrónico o teléfono').fill(cred.email);
    await page.getByPlaceholder('Contraseña').fill(cred.password);
    await page.getByRole('button', { name: 'Entrar' }).click();
    await expect(page).toHaveURL(/\/pwa\/home/, { timeout: 15_000 });
}

function irA(page, ruta) {
    return page.evaluate(
        (r) => document.querySelector('[data-v-app]').__vue_app__.config.globalProperties.$router.push(r),
        ruta
    );
}

function usuarioActual(page) {
    return page.evaluate(
        () => document.querySelector('[data-v-app]').__vue_app__.config.globalProperties.$pinia._s.get('auth').user.id
    );
}

/** Corta y devuelve la señal: el evento 'online' es lo que dispara la subida. */
async function volverLaSenal(page, endpoint) {
    const subida = page.waitForResponse(
        (r) => r.url().includes(endpoint) && r.request().method() === 'POST',
        { timeout: 30_000 }
    );
    await page.context().setOffline(false);
    return (await subida).json();
}

function esperarColaVacia(page, store) {
    return expect
        .poll(async () => (await idb(page, readAll, store)).length, { timeout: 15_000 })
        .toBe(0);
}

test.describe('cola offline: lo capturado sin señal llega al servidor (PWA-001)', () => {
    // Datos, no viewport; y cada corrida gasta un login (límite 5/min).
    test.skip(({ isMobile }) => Boolean(isMobile), 'lógica de datos: no depende del viewport');

    test.use({ permissions: ['geolocation'], geolocation: { latitude: 4.711, longitude: -74.072 } });

    // Un solo test con pasos: un solo login para todo el recorrido.
    test('pagos, visitas y gastos suben, lo dudoso queda en revisión y los errores sobreviven', async ({ page }) => {
        await loginAsCollector(page);

        // El login baja la cartera en segundo plano.
        await expect
            .poll(async () => (await idb(page, readAll, 'credits')).length, { timeout: 20_000 })
            .toBeGreaterThan(0);

        const credito = await idb(page, creditoConCuota);
        expect(credito, 'la demo no tiene un crédito con cuota pendiente').toBeTruthy();
        const yo = await usuarioActual(page);
        const hoy = new Date().toLocaleDateString('en-CA');

        await test.step('un cobro hecho sin señal sube al volver la conexión', async () => {
            // La pantalla se abre CON señal: en los tests el service worker está
            // bloqueado, así que sin red el chunk de la vista no cargaría. La señal
            // se corta antes de confirmar, que es cuando la app decide encolar.
            await irA(page, `/pwa/credits/${credito.id}/pay`);
            await expect(page.getByRole('button', { name: 'Confirmar Pago' })).toBeVisible({ timeout: 15_000 });
            await page.context().setOffline(true);

            // $1 (o $1.000 en modo simplificado): siempre por debajo del saldo.
            await page.locator('input[type="number"]').first().fill('1');
            await page.getByRole('button', { name: 'Confirmar Pago' }).click();

            // No se espera la pantalla de éxito: sin red su chunk no carga. Lo que
            // importa es que el cobro quedó en la cola.
            await expect
                .poll(async () => (await idb(page, readAll, 'pendingPayments')).length, { timeout: 15_000 })
                .toBe(1);

            const [enCola] = await idb(page, readAll, 'pendingPayments');
            expect(enCola.captured_by_user_id, 'el cobro debe llevar quién lo capturó').toBe(yo);

            const respuesta = await volverLaSenal(page, '/api/pwa/sync/payments');
            expect(respuesta.results[0]).toMatchObject({
                idempotency_key: enCola.idempotency_key,
                status: 'success',
            });
            await esperarColaVacia(page, 'pendingPayments');
        });

        await test.step('visitas y gastos también suben, y un cobro de hace 10 días queda en revisión', async () => {
            await page.context().setOffline(true);

            const hace10 = new Date(Date.now() - 10 * 86_400_000);
            const pagoViejo = randomUUID();
            await idb(page, putRows, {
                store: 'pendingPayments',
                rows: [{
                    idempotency_key: pagoViejo,
                    credit_id: credito.id,
                    amount: 1,
                    payment_method: 'cash',
                    payment_date: hace10.toLocaleDateString('en-CA'),
                    created_at_local: hace10.toISOString(),
                    captured_by_user_id: yo,
                }],
            });
            const visita = randomUUID();
            await idb(page, putRows, {
                store: 'pendingVisits',
                rows: [{
                    idempotency_key: visita,
                    credit_id: credito.id,
                    visit_type: 'no_payment',
                    reason: 'no_money',
                    promised_amount: 5,
                    is_simplified_amount: true,
                    visit_date: hoy,
                    created_at_local: new Date().toISOString(),
                    captured_by_user_id: yo,
                }],
            });
            const gasto = randomUUID();
            await idb(page, putRows, {
                store: 'pendingExpenses',
                rows: [{
                    idempotency_key: gasto,
                    amount: 1,
                    category: 'transportation',
                    date: hoy,
                    created_at_local: new Date().toISOString(),
                    captured_by_user_id: yo,
                }],
            });

            const visitas = page.waitForResponse((r) => r.url().includes('/api/pwa/sync/visits'), { timeout: 30_000 });
            const gastos = page.waitForResponse((r) => r.url().includes('/api/pwa/sync/expenses'), { timeout: 30_000 });
            const pagos = await volverLaSenal(page, '/api/pwa/sync/payments');

            expect(pagos.results[0]).toMatchObject({ idempotency_key: pagoViejo, status: 'held', reason: 'stale' });
            expect((await (await visitas).json()).results[0]).toMatchObject({ idempotency_key: visita, status: 'success' });
            expect((await (await gastos).json()).results[0]).toMatchObject({ idempotency_key: gasto, status: 'ok' });

            await esperarColaVacia(page, 'pendingPayments');
            await esperarColaVacia(page, 'pendingVisits');
            await esperarColaVacia(page, 'pendingExpenses');
        });

        await test.step('un error de una sesión anterior se ve y se envía a revisión', async () => {
            const clave = randomUUID();
            // Estado que deja una sesión anterior: el cobro agotó sus reintentos.
            // Antes la pantalla solo miraba un array en memoria y decía "Todo sincronizado".
            await idb(page, putRows, {
                store: 'pendingPayments',
                rows: [{
                    idempotency_key: clave,
                    credit_id: credito.id,
                    amount: 1,
                    payment_method: 'cash',
                    payment_date: hoy,
                    created_at_local: new Date().toISOString(),
                    captured_by_user_id: yo,
                    client_name: 'Cliente de prueba',
                    retry_count: 3,
                    permanent_error: true,
                    last_error: 'Error de prueba de una sesión anterior',
                }],
            });

            await irA(page, '/pwa/sync-errors');
            await expect(page.getByText('Error de prueba de una sesión anterior')).toBeVisible();

            const subida = page.waitForResponse(
                (r) => r.url().includes('/api/pwa/sync/payments') && r.request().method() === 'POST',
                { timeout: 30_000 }
            );
            await page.getByRole('button', { name: 'Enviar a revisión' }).click();
            const respuesta = await (await subida).json();

            expect(respuesta.results[0]).toMatchObject({ idempotency_key: clave, status: 'held', reason: 'manual' });
            await esperarColaVacia(page, 'pendingPayments');
            await expect(page.getByText('Todo sincronizado')).toBeVisible();
        });
    });
});
```

- [x] **Step 2: Ajustar `sync-purge.spec.js` para que su fila no suba**

Ese spec inyecta un pago en cola solo para proteger un crédito de la purga. Con la cola funcionando, el motor lo intentaría subir. En la fila (líneas 164-169), añadir:

```js
                    // No debe subir: solo representa "un pago todavía en cola".
                    permanent_error: true,
                    last_error: 'fila de prueba del E2E',
```

- [x] **Step 3: Correr el spec nuevo y ver que falla (sin el arreglo del cliente)**

Run: `npx playwright test tests/e2e/cola-offline.spec.js --project=chromium`
Expected: FAIL en el primer paso. Con el código actual el ítem encolado no trae `captured_by_user_id`, y aunque lo trajera, `waitForResponse('/api/pwa/sync/payments')` agotaría los 30 s porque la cola nunca sube (la consulta por índice booleano rechaza). La Task 13 comprueba el segundo motivo por separado.

- [x] **Step 4: Commit**

```bash
git add tests/e2e/cola-offline.spec.js tests/e2e/sync-purge.spec.js
git commit -F /home/jredondo/commit-msg.txt   # "test(e2e): la cola offline sube de punta a punta (falla hoy)"
```

---

### Task 9: Dexie v8 — la causa raíz

**Files:**
- Modify: `resources/js/pwa/db/index.js` (añadir v8 tras la v7, línea 116; borrar `clearAll`, líneas 122-141)

- [x] **Step 1: Añadir la versión 8**

Después del bloque `db.version(7)…`:

```js
/**
 * Version 8: quita `permanent_error` de los índices de las tres colas.
 *
 * IndexedDB no admite booleanos como clave: `where('permanent_error').equals(true)`
 * se traduce a IDBKeyRange.bound(true, true) y RECHAZA siempre, aunque la cola
 * esté vacía (Chromium: "not a valid key"; WebKit: "Provided data is inadequate").
 * Esa consulta, desde abril de 2026, impidió que subiera ningún pago, visita ni
 * gasto guardado sin señal (PWA-001). El campo sigue en los registros y se lee
 * con filter(); que no vuelva a indexarse.
 *
 * El upgrade además sella quién capturó lo que ya estaba en cola
 * (`captured_by_user_id`) con el dueño de los datos del dispositivo: si entra
 * otro usuario, el servidor no debe atribuírselo a él.
 */
db.version(8).stores({
    clients:         'id, name, identification, phone',
    credits:         'id, client_id, status, collector_user_id',
    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',
    pendingVisits:   '++localId, idempotency_key, credit_id, created_at_local',
    pendingExpenses: '++localId, idempotency_key, created_at_local',
    settings:        'id'
}).upgrade(async (tx) => {
    let dueno = null
    try {
        dueno = localStorage.getItem('pwa_data_owner_id')
    } catch {
        // Almacenamiento bloqueado: sin dueño conocido, el servidor atribuye a quien suba.
    }
    if (dueno === null || !/^\d+$/.test(dueno)) return

    const id = Number(dueno)
    for (const tabla of ['pendingPayments', 'pendingVisits', 'pendingExpenses']) {
        await tx.table(tabla).toCollection().modify((fila) => {
            if (fila.captured_by_user_id == null) fila.captured_by_user_id = id
        })
    }
})
```

- [x] **Step 2: Borrar `db.clearAll`**

Eliminar la función `db.clearAll` completa, con su comentario (líneas 122-141). En el comentario de `db.clearCaches`, cambiar la primera línea a:

```js
/**
 * Borra solo la cache de lectura. NUNCA toca la cola sin sincronizar, y no
 * existe otra función que la borre: un pago registrado sin señal NO existe en
 * ninguna otra parte hasta que el servidor lo recibe (aplicado o retenido).
```

- [x] **Step 3: Verificar que nadie más usa `clearAll`**

Run: `grep -rn "clearAll" resources/js/pwa`
Expected: solo `stores/auth.js` (se cambia en la Task 11). Ningún otro resultado.

- [x] **Step 4: Commit**

```bash
git add resources/js/pwa/db/index.js
git commit -F /home/jredondo/commit-msg.txt   # "fix(pwa): Dexie v8 sin índices booleanos en las colas (causa de PWA-001)"
```

---

### Task 10: Un solo motor de sincronización

**Files:**
- Modify: `resources/js/pwa/stores/sync.js` (reescritura completa)

- [x] **Step 1: Reemplazar `stores/sync.js` completo**

```js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import * as Sentry from '@sentry/vue'
import api from '../services/api'
import { db } from '../db'
import { useToast } from '../composables/useToast'
import { generateUUID } from '../utils/uuid'
import { useAuthStore } from './auth'

// Reintentos de un ítem que el SERVIDOR rechazó antes de sacarlo del envío
// automático. Los fallos de transporte (sin red, timeout, 5xx) no cuentan.
const MAX_SYNC_RETRIES = 3

const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i

/**
 * Las tres colas offline pasan por el mismo motor (`sincronizarCola`). Antes cada
 * una tenía su copia, y el mismo bug (consultar un booleano por índice, PWA-001)
 * vivía en las tres.
 *
 * `estado` traduce la respuesta del servidor a success | duplicate | held | error.
 */
const COLAS = {
    payments: {
        tabla: 'pendingPayments',
        endpoint: '/pwa/sync/payments',
        campo: 'payments',
        lote: 50,
        item: (p) => ({
            idempotency_key: p.idempotency_key,
            credit_id: p.credit_id,
            amount: p.amount,
            payment_method: p.payment_method,
            payment_date: p.payment_date,
            device_id: p.device_id,
            created_at_local: p.created_at_local,
            latitude: p.latitude ?? null,
            longitude: p.longitude ?? null,
            captured_by_user_id: p.captured_by_user_id ?? null,
            ...(p.send_to_review ? { hold: true } : {}),
        }),
        estado: (r) => r.status,
        error: (r) => r.error,
    },
    visits: {
        tabla: 'pendingVisits',
        endpoint: '/pwa/sync/visits',
        campo: 'visits',
        lote: 100,
        item: (v) => ({
            idempotency_key: v.idempotency_key,
            credit_id: v.credit_id,
            installment_id: v.installment_id ?? null,
            visit_type: v.visit_type,
            reason: v.reason ?? null,
            notes: v.notes ?? null,
            promised_date: v.promised_date ?? null,
            promised_amount: v.promised_amount ?? null,
            // PWA-010: la promesa se escribió en modo simplificado y el servidor
            // la convierte. Sin este flag llegaba mil veces menor.
            is_simplified_amount: Boolean(v.is_simplified_amount),
            visit_date: v.visit_date,
            visit_time: v.visit_time ?? null,
            latitude: v.latitude ?? null,
            longitude: v.longitude ?? null,
            device_id: v.device_id ?? null,
            captured_by_user_id: v.captured_by_user_id ?? null,
        }),
        estado: (r) => r.status,
        error: (r) => r.error,
    },
    expenses: {
        tabla: 'pendingExpenses',
        endpoint: '/pwa/sync/expenses',
        campo: 'expenses',
        lote: 100,
        item: (e) => ({
            idempotency_key: e.idempotency_key,
            amount: e.amount,
            category: e.category,
            notes: e.notes || null,
            date: e.date,
            is_simplified_amount: Boolean(e.is_simplified_amount),
            captured_by_user_id: e.captured_by_user_id ?? null,
        }),
        // El endpoint de gastos responde "ok" tanto para creado como para duplicado.
        estado: (r) => (r.status === 'ok' ? 'success' : r.status),
        error: (r) => r.message,
    },
}

// Pagos primero: son plata.
const ORDEN = ['payments', 'visits', 'expenses']
const TIPO_ERROR = { payments: 'payment', visits: 'visit', expenses: 'expense' }

/** Sin respuesta, 5xx, 429 o sesión caída: el lote no se juzga, se reintenta después. */
function esFalloDeTransporte(err) {
    const status = err?.response?.status
    return !status || status >= 500 || status === 429 || status === 401 || status === 419
}

function mensajeDe(err) {
    return err?.response?.data?.message || err?.message || 'Error desconocido'
}

/**
 * Manda a Sentry lo que no es un simple corte de señal: sin respuesta es el día
 * a día en la calle y solo haría ruido.
 */
function reportarFallo(err, tags) {
    if (err && err.request && !err.response) return
    Sentry.captureException(err, {
        tags: { ...tags, http_status: err?.response?.status ?? 'sin_respuesta' },
    })
}

export const useSyncStore = defineStore('sync', () => {
    // State
    const pendingPayments = ref([])
    const pendingVisits = ref([])
    const pendingExpenses = ref([])
    const syncing = ref(false)
    const lastSync = ref(localStorage.getItem('pwa_last_sync') || null)

    const filas = { payments: pendingPayments, visits: pendingVisits, expenses: pendingExpenses }

    // Un ítem es "mío" si lo capturé yo o si viene de antes de sellar el capturador.
    function esMio(item) {
        const yo = useAuthStore().user?.id
        return item.captured_by_user_id == null || item.captured_by_user_id === yo
    }

    const todos = computed(() => [...pendingPayments.value, ...pendingVisits.value, ...pendingExpenses.value])

    // Getters
    const pendingPaymentsCount = computed(() => pendingPayments.value.filter(esMio).length)
    const pendingVisitsCount = computed(() => pendingVisits.value.filter(esMio).length)
    const pendingExpensesCount = computed(() => pendingExpenses.value.filter(esMio).length)
    const pendingCount = computed(() =>
        pendingPaymentsCount.value + pendingVisitsCount.value + pendingExpensesCount.value
    )
    /** Registros de otro usuario de este teléfono que se están enviando a revisión. */
    const foreignPendingCount = computed(() => todos.value.filter((x) => !esMio(x)).length)
    const hasPending = computed(() => pendingCount.value > 0)
    /** Hay algo que el motor intentaría enviar (lo que agotó reintentos espera una acción). */
    const hasRetryable = computed(() => todos.value.some((x) => !x.permanent_error))
    const pendingAmount = computed(() =>
        pendingPayments.value.filter(esMio).reduce((sum, p) => sum + p.amount, 0)
    )

    /**
     * Errores de sincronización, leídos de la cola persistida en IndexedDB. Antes
     * vivían en un array en memoria: se perdían al recargar y cada tipo de sync
     * vaciaba los de los otros.
     */
    const syncErrors = computed(() =>
        ORDEN.flatMap((tipo) =>
            filas[tipo].value
                .filter((x) => x.last_error)
                .map((x) => ({
                    type: TIPO_ERROR[tipo],
                    idempotency_key: x.idempotency_key,
                    error: x.last_error,
                    permanent: Boolean(x.permanent_error),
                    item: x,
                }))
        )
    )

    // ── Lectura de las colas ─────────────────────────────────────────────────

    async function cargar(tipo) {
        try {
            filas[tipo].value = await db[COLAS[tipo].tabla].toArray()
        } catch (err) {
            console.error(`[Sync] No se pudo leer la cola ${tipo}`, err)
        }
    }

    const loadPendingPayments = () => cargar('payments')
    const loadPendingVisits = () => cargar('visits')
    const loadPendingExpenses = () => cargar('expenses')

    /** Relee las tres colas de IndexedDB (arranque sin red, pantalla de errores). */
    async function preloadOfflineData() {
        await Promise.all(ORDEN.map(cargar))
    }

    // ── Encolar ──────────────────────────────────────────────────────────────

    /** Lanza una ronda en segundo plano si hay señal; nunca bloquea a quien encola. */
    function pedirRonda() {
        if (!navigator.onLine) return
        syncAll().catch((err) => console.error('[Sync] Ronda fallida', err))
    }

    function capturador() {
        return useAuthStore().user?.id ?? null
    }

    async function queuePayment(paymentData) {
        const payment = {
            ...paymentData,
            idempotency_key: generateUUID(),
            created_at_local: new Date().toISOString(),
            captured_by_user_id: capturador(),
        }

        // IndexedDB primero: es la única copia del cobro hasta que el servidor lo reciba.
        await db.pendingPayments.add(payment)
        pendingPayments.value.push(payment)

        // Saldo optimista para que las listas lo reflejen ya. Si falla, el cobro sigue en cola.
        try {
            await db.deductCreditBalance(payment.credit_id, payment.amount)
        } catch (err) {
            console.warn('[Sync] Optimistic balance deduction failed (payment still queued):', err)
        }

        pedirRonda()
        return payment
    }

    async function queueVisit(visitData) {
        const visit = {
            ...visitData,
            idempotency_key: generateUUID(),
            created_at_local: new Date().toISOString(),
            captured_by_user_id: capturador(),
        }

        await db.pendingVisits.add(visit)
        pendingVisits.value.push(visit)

        pedirRonda()
        return visit
    }

    async function queueExpense(expenseData) {
        const expense = {
            ...expenseData,
            idempotency_key: generateUUID(),
            created_at_local: new Date().toISOString(),
            captured_by_user_id: capturador(),
        }

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

        pedirRonda()
        return expense
    }

    // ── Motor ────────────────────────────────────────────────────────────────

    async function marcarError(tabla, item, mensaje) {
        const reintentos = (item.retry_count || 0) + 1
        await tabla.where('idempotency_key').equals(item.idempotency_key).modify({
            retry_count: reintentos,
            last_error: mensaje,
            permanent_error: reintentos >= MAX_SYNC_RETRIES,
        })
    }

    /** Si el servidor informa que el crédito cambió de estado, reflejarlo ya en la caché. */
    async function reflejarEstadoCredito(creditId, resultado) {
        if (!creditId || !resultado.credit_new_status) return

        const cambios = { status: resultado.credit_new_status }
        if (resultado.credit_is_paid) cambios.remaining_balance = 0
        await db.credits.update(creditId, cambios)

        if (!resultado.credit_is_paid) return
        const cuotas = await db.installments.where('credit_id').equals(creditId).toArray()
        for (const cuota of cuotas) {
            if (cuota.status !== 'paid') {
                await db.installments.update(cuota.id, {
                    status: 'paid',
                    amount_paid: cuota.total_amount ?? cuota.amount_paid,
                    paid_date: new Date().toLocaleDateString('en-CA'),
                })
            }
        }
    }

    async function sincronizarCola(tipo) {
        const cfg = COLAS[tipo]
        const tabla = db[cfg.tabla]
        const r = { enviados: 0, enRevision: 0, conError: 0, transporte: false }

        // filter(), NUNCA where() sobre un booleano: ver db/index.js, versión 8.
        const pendientes = await tabla.filter((x) => !x.permanent_error).toArray()

        for (let i = 0; i < pendientes.length; i += cfg.lote) {
            const lote = pendientes.slice(i, i + cfg.lote)
            let respuesta

            try {
                respuesta = await api.post(cfg.endpoint, { [cfg.campo]: lote.map(cfg.item) })
            } catch (err) {
                reportarFallo(err, { cola: tipo, tamano_lote: lote.length })
                if (esFalloDeTransporte(err)) {
                    r.transporte = true
                    break
                }
                // El servidor rechazó el lote entero (p. ej. 403). Por datos no
                // debería pasar: valida ítem a ítem. Cuenta como error de cada
                // ítem para que no se reintente para siempre.
                for (const item of lote) await marcarError(tabla, item, mensajeDe(err))
                r.conError += lote.length
                continue
            }

            for (const resultado of respuesta.data?.results ?? []) {
                // Por clave; si la clave vino mal (el servidor la rechaza como error),
                // por la posición en el lote que el servidor devuelve en `index`.
                const item = lote.find((x) => x.idempotency_key === resultado.idempotency_key)
                    ?? (Number.isInteger(resultado.index) ? lote[resultado.index] : undefined)
                if (!item) continue
                const estado = cfg.estado(resultado)

                if (estado === 'success' || estado === 'duplicate' || estado === 'held') {
                    // El servidor ya lo tiene (aplicado, repetido o retenido para
                    // revisión): recién ahora el teléfono puede soltarlo.
                    await tabla.where('idempotency_key').equals(item.idempotency_key).delete()
                    if (estado === 'held') r.enRevision++
                    else r.enviados++
                    if (tipo === 'payments' && estado === 'success') {
                        await reflejarEstadoCredito(item.credit_id, resultado)
                    }
                } else {
                    await marcarError(tabla, item, cfg.error(resultado) || 'Error desconocido')
                    r.conError++
                    Sentry.captureMessage(`Ítem rechazado en la cola ${tipo}`, {
                        level: 'warning',
                        tags: { cola: tipo, retry_count: (item.retry_count || 0) + 1 },
                    })
                }
            }
        }

        return r
    }

    async function ejecutarRonda() {
        syncing.value = true
        const total = { enviados: 0, enRevision: 0, conError: 0, transporte: false, refrescado: false }

        try {
            for (const tipo of ORDEN) {
                const r = await sincronizarCola(tipo)
                total.enviados += r.enviados
                total.enRevision += r.enRevision
                total.conError += r.conError
                if (r.transporte) {
                    total.transporte = true
                    break
                }
            }

            // Lo que se soltó ya no protege a su crédito de la purga: el snapshot
            // trae los saldos reales (incluido revertir lo descontado de un retenido).
            if (total.enviados + total.enRevision > 0) {
                const datos = await syncData()
                total.refrescado = Boolean(datos.success)
            }
        } catch (err) {
            // Fallo inesperado (IndexedDB, un bug): la cola queda como estaba.
            console.error('[Sync] Falló la ronda de sincronización', err)
            reportarFallo(err, { cola: 'ronda' })
            total.transporte = true
        } finally {
            await preloadOfflineData()
            syncing.value = false
        }

        return { success: !total.transporte && total.conError === 0, ...total }
    }

    function avisarResultado(r, manual) {
        const toast = useToast()
        const partes = []
        if (r.enviados) partes.push(`${r.enviados} enviado${r.enviados !== 1 ? 's' : ''}`)
        if (r.enRevision) partes.push(`${r.enRevision} en revisión`)
        if (r.conError) partes.push(`${r.conError} con error`)

        if (partes.length === 0) {
            if (!manual) return
            if (r.transporte) {
                toast.error('No se pudo conectar con el servidor. Se reintentará solo.')
            } else if (syncErrors.value.length > 0) {
                toast.warning(`${syncErrors.value.length} con error: revísalos en Errores de sincronización.`)
            } else {
                toast.info('No hay nada pendiente por sincronizar.')
            }
            return
        }

        const texto = partes.join(' · ') + (r.transporte ? ' · el resto se reintentará' : '')
        if (r.conError || r.transporte) toast.warning(texto)
        else toast.success(texto)
    }

    let ronda = null

    /**
     * Sube las tres colas. Es el ÚNICO punto de entrada: una llamada mientras otra
     * corre recibe el resultado de esa (antes devolvía 'already_syncing' y visitas
     * y gastos se quedaban sin subir).
     *
     * @param {{manual?: boolean}} opciones  manual: la pidió el usuario con un botón,
     *        así que se informa aunque no haya nada; las automáticas solo avisan si algo cambió.
     * @returns {Promise<{success: boolean, enviados?: number, enRevision?: number, conError?: number, transporte?: boolean, refrescado?: boolean, reason?: string}>}
     */
    function syncAll({ manual = false } = {}) {
        if (!navigator.onLine) {
            if (manual) useToast().warning('Sin conexión. Se sincronizará solo al volver la señal.')
            return Promise.resolve({ success: false, reason: 'offline' })
        }

        // Tras recargar, el usuario persiste pero el token no: sin él, la petición
        // daría 401 y un redirect al login en medio del trabajo.
        if (!useAuthStore().token) {
            return Promise.resolve({ success: false, reason: 'no_session' })
        }

        if (ronda) {
            ronda.manual = ronda.manual || manual
            return ronda.promesa
        }

        const actual = { manual, promesa: null }
        actual.promesa = ejecutarRonda()
            .then((resultado) => {
                avisarResultado(resultado, actual.manual)
                return resultado
            })
            .finally(() => {
                ronda = null
            })
        ronda = actual
        return actual.promesa
    }

    // ── Acciones de la pantalla de errores ──────────────────────────────────

    /** Devuelve un ítem con error al envío automático y lanza una ronda. */
    async function retryItem(tipo, idempotencyKey) {
        await db[COLAS[tipo].tabla]
            .where('idempotency_key')
            .equals(idempotencyKey)
            .modify({ retry_count: 0, permanent_error: false, last_error: null })
        await cargar(tipo)
        return syncAll({ manual: true })
    }

    /** "Reintentar todo": todos los ítems con error vuelven al motor en una sola ronda. */
    async function retryAllErrors() {
        await Promise.all(
            ORDEN.map((tipo) =>
                db[COLAS[tipo].tabla]
                    .filter((x) => Boolean(x.last_error))
                    .modify({ retry_count: 0, permanent_error: false, last_error: null })
            )
        )
        await preloadOfflineData()
        return syncAll({ manual: true })
    }

    /**
     * "Enviar a revisión": el servidor guarda el cobro aparte, sin tocar saldos, y
     * el admin decide. Reemplaza al "Descartar" que borraba plata cobrada.
     */
    async function sendPaymentToReview(idempotencyKey) {
        const fila = await db.pendingPayments.where('idempotency_key').equals(idempotencyKey).first()
        if (!fila) return { success: false, reason: 'not_found' }

        const cambios = { send_to_review: true, retry_count: 0, permanent_error: false, last_error: null }
        // Una clave que no es UUID nunca llegó al servidor (la rechaza antes de mirar
        // nada), así que se puede cambiar sin riesgo de duplicar.
        if (!UUID.test(String(fila.idempotency_key))) cambios.idempotency_key = generateUUID()

        await db.pendingPayments.update(fila.localId, cambios)
        await cargar('payments')
        return syncAll({ manual: true })
    }

    /** Borra una visita o un gasto de la cola. Un cobro no se borra: se envía a revisión. */
    async function discardItem(tipo, idempotencyKey) {
        if (tipo === 'payments') {
            throw new Error('Un cobro no se descarta: se envía a revisión.')
        }
        await db[COLAS[tipo].tabla].where('idempotency_key').equals(idempotencyKey).delete()
        await cargar(tipo)
    }

    // ── Snapshot de lectura ──────────────────────────────────────────────────

    async function syncData() {
        if (!navigator.onLine) {
            return { success: false, reason: 'offline' }
        }

        try {
            const response = await api.get('/pwa/sync/data', {
                params: { days: 7 }
            })

            const data = response.data

            // Defensive || [] fallbacks: prevents bulkPut(undefined) TypeError
            // from silently clearing the IndexedDB write and leaving the cache empty.
            const snapshot = {
                clients:      data.clients         || [],
                credits:      data.credits         || [],
                installments: data.installments    || [],
                payments:     data.recent_payments || [],
            }

            // El snapshot REEMPLAZA el alcance sincronizado (ver db.applySyncSnapshot).
            // La purga borra, así que solo se activa si el cuerpo tiene la forma
            // esperada; si dudamos, fusionamos.
            const isCompleteSnapshot = Boolean(data?.meta)
                && Array.isArray(data.clients)
                && Array.isArray(data.credits)
                && Array.isArray(data.installments)
                && Array.isArray(data.recent_payments)

            const { removedCredits } = await db.applySyncSnapshot(snapshot, { prune: isCompleteSnapshot })

            if (!isCompleteSnapshot) {
                console.warn('[Sync] Respuesta sin forma de snapshot: se fusiona sin purgar')
            } else if (removedCredits > 0) {
                console.info(`[Sync] ${removedCredits} crédito(s) fuera de alcance purgados del caché local`)
            }

            lastSync.value = data.synced_at
            localStorage.setItem('pwa_last_sync', lastSync.value)

            return { success: true, meta: data.meta }

        } catch (err) {
            console.error('[Sync] Data sync failed:', err?.response?.data || err.message, err)
            return { success: false, error: err.message }
        }
    }

    async function checkForUpdates() {
        if (!navigator.onLine || !lastSync.value) {
            return { hasUpdates: false }
        }

        try {
            const response = await api.get('/pwa/sync/check', {
                params: { last_sync: lastSync.value }
            })

            if (response.data.has_updates) {
                await syncData()
            }

            return { hasUpdates: response.data.has_updates }

        } catch (err) {
            console.error('[Sync] Check updates failed', err)
            return { hasUpdates: false, error: err.message }
        }
    }

    // Initialize - load pending data
    preloadOfflineData()

    return {
        // State
        pendingPayments,
        pendingVisits,
        pendingExpenses,
        syncing,
        lastSync,
        // Getters
        syncErrors,
        pendingPaymentsCount,
        pendingVisitsCount,
        pendingExpensesCount,
        pendingCount,
        foreignPendingCount,
        hasPending,
        hasRetryable,
        pendingAmount,
        // Actions
        loadPendingPayments,
        loadPendingVisits,
        loadPendingExpenses,
        preloadOfflineData,
        queuePayment,
        queueVisit,
        queueExpense,
        syncAll,
        retryItem,
        retryAllErrors,
        sendPaymentToReview,
        discardItem,
        syncData,
        checkForUpdates,
    }
}, {
    persist: {
        key: 'credify-pwa-sync',
        paths: ['lastSync']
    }
})
```

- [x] **Step 2: Buscar llamadas a lo que se eliminó**

Run: `grep -rn "syncPendingPayments\|syncPendingVisits\|syncPendingExpenses\|removePendingPayment\|removePendingVisit\|removePendingExpense\|retryPayment(\|syncErrors =" resources/js/pwa`
Expected: resultados solo en `SyncIndicator.vue`, `CollectorHome.vue`, `PaymentsHistoryView.vue`, `ProfileView.vue` y `SyncErrorsView.vue`. Se corrigen en la Task 12; no hacer commit hasta que el build pase (Step 3 de la Task 12).

- [x] **Step 3: Commit (junto con la Task 11)**

No commitear aún: el store nuevo rompe a sus llamadores hasta la Task 12. Se commitean juntas las Tasks 10-12 cuando `npm run build` pase.

---

### Task 11: Disparadores, login y Sentry

**Files:**
- Modify: `resources/js/pwa/stores/auth.js`
- Modify: `resources/js/pwa/App.vue:78-117`
- Modify: `resources/js/pwa/main.js:55-66`

- [x] **Step 1: `auth.js` — imports**

Añadir tras la línea 7:

```js
import * as Sentry from '@sentry/vue'
import { useSyncStore } from './sync'
```

- [x] **Step 2: `auth.js` — login**

Justo después de `setBearerToken(response.data.token)` (línea 107):

```js
            // Solo el id: nada de nombre, correo ni cédula en Sentry.
            Sentry.setUser({ id: String(user.value.id) })
```

Reemplazar la llamada `await discardDataFromAnotherUser(user.value?.id)` y su comentario (líneas 109-112) por:

```js
            // Si los datos del dispositivo son de OTRO usuario, se borra la caché
            // de lectura. Las colas NO: se suben igual, cada ítem con quién lo
            // capturó, y el servidor retiene los cobros ajenos para revisión.
            await resetCacheForNewUser(user.value?.id)
```

Reemplazar el bloque de `syncInitialData().catch(...)` y su comentario (líneas 118-126) por:

```js
            // En segundo plano, sin bloquear la entrada: primero sube lo que haya
            // en cola (propio o de quien usó el teléfono antes) y después baja la
            // cartera, así el snapshot ya incluye lo que se acaba de aplicar.
            const syncStore = useSyncStore()
            syncStore.syncAll()
                .then((ronda) => (ronda.refrescado ? null : syncStore.syncData()))
                .catch((err) => console.error('[Auth] Sincronización inicial falló:', err))
```

- [x] **Step 3: `auth.js` — cambio de usuario sin borrar colas**

Reemplazar `discardDataFromAnotherUser` completa (líneas 150-169) por:

```js
    /**
     * Si en el dispositivo quedaron datos de OTRO usuario, se borra la caché de
     * lectura (su cartera, sus clientes). Las colas NO se tocan: cada ítem lleva
     * `captured_by_user_id`, y el servidor atribuye cada uno a quien lo capturó
     * (los cobros ajenos quedan retenidos para revisión). Antes aquí se llamaba
     * a `db.clearAll()` y se perdían, sin aviso, los cobros sin subir del
     * usuario anterior (PWA-011).
     */
    async function resetCacheForNewUser(userId) {
        if (! userId) return

        let previous = null
        try {
            previous = localStorage.getItem(DATA_OWNER_KEY)
        } catch (err) {
            // Modo privado o almacenamiento bloqueado: sin marca no hay nada que comparar.
        }

        if (previous !== null && previous !== String(userId)) {
            await db.clearCaches()
        }

        try {
            localStorage.setItem(DATA_OWNER_KEY, String(userId))
        } catch (err) {
            // Idem: no poder recordarlo no debe impedir el inicio de sesión.
        }
    }
```

- [x] **Step 4: `auth.js` — `endSession`, `fetchUser` y eliminar `syncInitialData`**

En `endSession()`, tras `resetAnalytics()`: `Sentry.setUser(null)`.

En `fetchUser()`, tras `user.value = response.data`: `Sentry.setUser({ id: String(response.data.id) })`.

En el comentario de `endSession` (línea 175), cambiar "que hacía `db.clearAll()`" por "que borraba la base local".

Eliminar la función `syncInitialData` completa (líneas 235-276) y quitarla del objeto que devuelve el store (línea 324).

Run: `grep -rn "syncInitialData\|discardDataFromAnotherUser\|clearAll" resources/js/pwa`
Expected: sin resultados.

- [x] **Step 5: `App.vue` — disparadores**

Reemplazar el bloque desde `// Online/offline listeners` (línea 82) hasta el final de `onUnmounted` (línea 117) por:

```js
// La cola sube al volver la señal, al volver la app a primer plano y cada pocos
// minutos mientras quede algo. Antes solo el evento 'online' la lanzaba: un cobro
// guardado sin señal esperaba a que la conexión se cortara y volviera.
const INTERVALO_SYNC_MS = 3 * 60 * 1000

async function sincronizarSiHaceFalta() {
    if (document.visibilityState !== 'visible') return
    if (!authStore.isFullyAuthenticated || !navigator.onLine) return
    // Se relee de IndexedDB antes de decidir: otra pestaña o una sesión anterior
    // pudo dejar ítems que este proceso todavía no tiene en memoria.
    await syncStore.preloadOfflineData()
    if (!syncStore.hasRetryable) return
    syncStore.syncAll()
}

let _syncOnReconnectTimer = null
let _syncInterval = null

const updateOnlineStatus = () => {
    isOnline.value = navigator.onLine

    if (isOnline.value) {
        // Debounce: con señal intermitente 'online' se dispara varias veces seguidas.
        clearTimeout(_syncOnReconnectTimer)
        _syncOnReconnectTimer = setTimeout(sincronizarSiHaceFalta, 2000)
    }
}

onMounted(() => {
    window.addEventListener('online', updateOnlineStatus)
    window.addEventListener('offline', updateOnlineStatus)
    document.addEventListener('visibilitychange', sincronizarSiHaceFalta)
    _syncInterval = setInterval(sincronizarSiHaceFalta, INTERVALO_SYNC_MS)

    if (authStore.isAuthenticated) {
        if (navigator.onLine) {
            // Online: re-hydrate user profile and token.
            // fetchUser() returns early if token is missing (first load after
            // a hard reload) — that's fine, the 401 interceptor handles it.
            authStore.fetchUser()
        } else {
            // Offline: no API calls possible. Pre-warm the pending queues so
            // badge counts are accurate from IndexedDB immediately.
            syncStore.preloadOfflineData()
        }
    }
})

onUnmounted(() => {
    window.removeEventListener('online', updateOnlineStatus)
    window.removeEventListener('offline', updateOnlineStatus)
    document.removeEventListener('visibilitychange', sincronizarSiHaceFalta)
    clearTimeout(_syncOnReconnectTimer)
    clearInterval(_syncInterval)
})
```

- [x] **Step 6: `main.js` — transporte offline de Sentry**

En `Sentry.init({...})`, después de `sendDefaultPii: false,`:

```js
        // Sin señal los eventos se guardan en IndexedDB y salen al volver la
        // conexión: justo los fallos de la cola ocurren en esos momentos.
        transport: Sentry.makeBrowserOfflineTransport(Sentry.makeFetchTransport),
```

- [x] **Step 7: No commitear todavía** (ver Task 10, Step 3).

---

### Task 12: Pantallas: contador, avisos, errores y "Enviar a revisión"

**Files:**
- Modify: `resources/js/pwa/components/ui/SyncIndicator.vue` (reescritura)
- Modify: `resources/js/pwa/components/home/CollectorHome.vue:95-115, 134-135, 212-217`
- Modify: `resources/js/pwa/views/PaymentsHistoryView.vue:110-112, 252-257`
- Modify: `resources/js/pwa/views/ProfileView.vue:241-289`
- Modify: `resources/js/pwa/views/SyncErrorsView.vue` (botones y script)

- [x] **Step 1: `SyncIndicator.vue` completo**

```vue
<template>
    <div
        @click="handleClick"
        role="button"
        :aria-label="tieneErrores ? 'Ver errores de sincronización' : 'Sincronizar pendientes'"
        class="fixed top-0 right-4 text-white px-3 py-2 rounded-b-lg text-sm font-medium z-40 cursor-pointer shadow-lg safe-area-top"
        :class="tieneErrores ? 'bg-red-600' : 'bg-emerald-600'"
    >
        <span class="inline-flex items-center gap-2">
            <svg class="w-4 h-4 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
                    d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
            </svg>
            {{ count }} pendiente{{ count !== 1 ? 's' : '' }}<template v-if="tieneErrores"> · {{ errores }} con error</template>
        </span>
    </div>
</template>

<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useSyncStore } from '../../stores/sync'

defineProps({
    count: {
        type: Number,
        default: 0
    }
})

const router = useRouter()
const syncStore = useSyncStore()

const errores = computed(() => syncStore.syncErrors.length)
const tieneErrores = computed(() => errores.value > 0)

// Con errores lleva a donde se resuelven; sin ellos, sincroniza (la ronda informa el resultado).
function handleClick() {
    if (tieneErrores.value) {
        router.push({ name: 'sync-errors' })
        return
    }
    syncStore.syncAll({ manual: true })
}
</script>

<style scoped>
.safe-area-top {
    top: max(0px, env(safe-area-inset-top));
}
</style>
```

- [x] **Step 2: `CollectorHome.vue` — aviso de pendientes**

Reemplazar el bloque `<!-- ── Sync pending ── -->` (líneas 95-112) por:

```html
        <!-- ── Sync pending ── -->
        <div v-if="pendingSync > 0 || foreignPending > 0"
             class="rounded-2xl px-4 py-3.5 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-500/20">
            <div v-if="pendingSync > 0" class="flex items-center justify-between">
                <div class="flex items-center gap-2.5">
                    <svg class="w-5 h-5 text-amber-500 dark:text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
                            d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
                    </svg>
                    <span class="text-amber-700 dark:text-amber-300 text-sm font-medium">{{ pendingSync }} pendiente{{ pendingSync !== 1 ? 's' : '' }} por sincronizar</span>
                </div>
                <button
                    @click="syncNow"
                    :disabled="syncing"
                    class="text-amber-600 dark:text-amber-400 text-sm font-semibold cursor-pointer disabled:opacity-50 active:opacity-70"
                >
                    {{ syncing ? '...' : 'Sincronizar' }}
                </button>
            </div>
            <RouterLink
                v-if="syncErrorsCount > 0"
                :to="{ name: 'sync-errors' }"
                class="mt-2 block text-sm font-semibold text-red-600 dark:text-red-400"
            >
                {{ syncErrorsCount }} con error · Ver y resolver
            </RouterLink>
            <p v-if="foreignPending > 0" class="mt-2 text-xs text-amber-700 dark:text-amber-300">
                {{ foreignPending }} registro{{ foreignPending !== 1 ? 's' : '' }} de otro usuario de este teléfono se {{ foreignPending !== 1 ? 'están' : 'está' }} enviando a revisión.
            </p>
        </div>
```

Junto a `const syncing = computed(() => syncStore.syncing)` (línea 135), añadir:

```js
const foreignPending   = computed(() => syncStore.foreignPendingCount)
const syncErrorsCount  = computed(() => syncStore.syncErrors.length)
```

Reemplazar `syncNow` (líneas 212-217):

```js
async function syncNow() {
    // La ronda informa su resultado; el dashboard se refresca si hay señal.
    await syncStore.syncAll({ manual: true })
    if (navigator.onLine) await dashboardStore.fetchDashboard()
}
```

- [x] **Step 3: `PaymentsHistoryView.vue`**

Reemplazar el `<span>` de estado (líneas 110-112) por:

```html
                        <RouterLink
                            v-if="payment._permanent_error"
                            :to="{ name: 'sync-errors' }"
                            class="text-[10px] font-semibold text-red-600 dark:text-red-400 underline"
                        >
                            Error · resolver
                        </RouterLink>
                        <span v-else class="text-[10px] font-medium text-slate-500">
                            {{ payment._pending ? 'Pendiente' : 'Sincronizado' }}
                        </span>
```

Reemplazar `syncNow` (líneas 252-257):

```js
async function syncNow() {
    await syncStore.syncAll({ manual: true })
    if (navigator.onLine) await fetchPayments()
}
```

- [x] **Step 4: `ProfileView.vue`**

Reemplazar `forceSync` (líneas 241-255):

```js
async function forceSync() {
    if (!isOnline.value) {
        toast.warning('Sin conexión')
        return
    }

    // La ronda informa su propio resultado (enviados, en revisión, con error).
    // Si no bajó datos, porque no había nada que subir, se refresca aparte.
    const ronda = pendingCount.value > 0 ? await syncStore.syncAll({ manual: true }) : null
    if (ronda?.refrescado || ronda?.transporte) return

    const datos = await syncStore.syncData()
    if (datos.success) {
        toast.success('Datos actualizados')
    } else {
        toast.error('No se pudo actualizar. Revisa la conexión.')
    }
}
```

En `logout()`, reemplazar las dos líneas del medio del `confirm` (líneas 276-277):

```js
            'Se guardan en este dispositivo y se enviarán cuando vuelvas a entrar. ' +
            'Si entra otra persona, se enviarán a revisión a tu nombre.\n\n' +
```

Y el comentario de arriba (líneas 273-275):

```js
        // El mensaje dice la verdad: la cola se conserva, y si entra otra persona
        // se sube igual a nombre de quien la capturó (los cobros, a revisión).
```

- [x] **Step 5: `SyncErrorsView.vue` — plantilla**

En la sección de pagos, justo antes de `<div class="space-y-2">` (línea 31), añadir:

```html
                <p class="text-xs text-slate-500 mb-2 px-1">
                    Un cobro no se borra del teléfono: si no se puede aplicar, envíalo a revisión y la oficina decide.
                </p>
```

Reemplazar el botón "Descartar" de pagos (líneas 70-76) por:

```html
                            <button
                                @click="enviarARevision(item)"
                                :disabled="item.retrying || syncStore.syncing"
                                class="flex-1 py-3 text-sm font-semibold text-amber-700 dark:text-amber-400 disabled:opacity-40 card-press"
                            >
                                Enviar a revisión
                            </button>
```

En visitas, cambiar `@click="discardVisit(item)"` (línea 132) por `@click="pedirDescarte(item)"`, y después del `</div>` que cierra la fila de botones de la visita (línea 138), añadir:

```html
                        <div v-if="confirmando === item.idempotency_key"
                             class="px-4 py-3 border-t border-slate-100 dark:border-white/5 bg-slate-50 dark:bg-white/5">
                            <p class="text-xs text-slate-600 dark:text-slate-300">Esta visita se borrará del teléfono y no llegará al servidor. ¿Seguro?</p>
                            <div class="mt-2 flex gap-2">
                                <button @click="discardVisit(item)" class="flex-1 py-2 rounded-xl text-sm font-semibold text-white bg-red-600 card-press">Sí, borrar</button>
                                <button @click="confirmando = null" class="flex-1 py-2 rounded-xl text-sm font-medium text-slate-600 dark:text-slate-300 bg-slate-200 dark:bg-white/10 card-press">Cancelar</button>
                            </div>
                        </div>
```

En gastos, lo mismo: `@click="discardExpense(item)"` (línea 194) pasa a `@click="pedirDescarte(item)"`, y después del `</div>` de sus botones (línea 200):

```html
                        <div v-if="confirmando === item.idempotency_key"
                             class="px-4 py-3 border-t border-slate-100 dark:border-white/5 bg-slate-50 dark:bg-white/5">
                            <p class="text-xs text-slate-600 dark:text-slate-300">Este gasto se borrará del teléfono y no llegará al servidor. ¿Seguro?</p>
                            <div class="mt-2 flex gap-2">
                                <button @click="discardExpense(item)" class="flex-1 py-2 rounded-xl text-sm font-semibold text-white bg-red-600 card-press">Sí, borrar</button>
                                <button @click="confirmando = null" class="flex-1 py-2 rounded-xl text-sm font-medium text-slate-600 dark:text-slate-300 bg-slate-200 dark:bg-white/10 card-press">Cancelar</button>
                            </div>
                        </div>
```

- [x] **Step 6: `SyncErrorsView.vue` — reemplazar el `<script setup>` completo**

```vue
<script setup>
import { computed, reactive, ref, onMounted } from 'vue'
import { useSyncStore } from '../stores/sync'
import { useSettingsStore } from '../stores/settings'

const syncStore = useSyncStore()
const settings  = useSettingsStore()

const retryingMap = reactive({})
const confirmando = ref(null)

// Los errores salen de la cola en IndexedDB: sobreviven a una recarga y un tipo
// ya no borra los de otro. Se relee al entrar por si una ronda automática los cambió.
onMounted(() => syncStore.preloadOfflineData())

function erroresDe(tipo) {
    return syncStore.syncErrors.filter(e => e.type === tipo)
}

const paymentErrors = computed(() =>
    erroresDe('payment').map(e => ({
        idempotency_key: e.idempotency_key,
        error:           e.error,
        clientName:      e.item.client_name || `Crédito #${e.item.credit_id ?? '?'}`,
        amount:          e.item.amount ?? 0,
        paymentDate:     e.item.payment_date ?? '—',
        retrying:        !!retryingMap[e.idempotency_key],
    }))
)

const visitErrors = computed(() =>
    erroresDe('visit').map(e => ({
        idempotency_key: e.idempotency_key,
        error:           e.error,
        clientName:      e.item.client_name || `Crédito #${e.item.credit_id ?? '?'}`,
        visitType:       e.item.visit_type ?? '—',
        visitDate:       e.item.visit_date ?? '—',
        retrying:        !!retryingMap[e.idempotency_key],
    }))
)

const expenseErrors = computed(() =>
    erroresDe('expense').map(e => ({
        idempotency_key: e.idempotency_key,
        error:           e.error,
        category:        e.item.category ?? '—',
        amount:          e.item.amount ?? 0,
        expenseDate:     e.item.date ?? '—',
        retrying:        !!retryingMap[e.idempotency_key],
    }))
)

const totalErrors = computed(() => paymentErrors.value.length + visitErrors.value.length + expenseErrors.value.length)

function formatAmount(amount) {
    return settings.formatAmount(amount)
}

async function conEstado(item, accion) {
    retryingMap[item.idempotency_key] = true
    try {
        await accion()
    } finally {
        delete retryingMap[item.idempotency_key]
    }
}

// Reintentar devuelve el ítem al motor; el resultado real lo informa la ronda
// (antes la visita anunciaba éxito sin comprobarlo).
const retryPayment     = (item) => conEstado(item, () => syncStore.retryItem('payments', item.idempotency_key))
const retryVisit       = (item) => conEstado(item, () => syncStore.retryItem('visits', item.idempotency_key))
const retryExpense     = (item) => conEstado(item, () => syncStore.retryItem('expenses', item.idempotency_key))
const enviarARevision  = (item) => conEstado(item, () => syncStore.sendPaymentToReview(item.idempotency_key))

function pedirDescarte(item) {
    confirmando.value = item.idempotency_key
}

async function discardVisit(item) {
    confirmando.value = null
    await syncStore.discardItem('visits', item.idempotency_key)
}

async function discardExpense(item) {
    confirmando.value = null
    await syncStore.discardItem('expenses', item.idempotency_key)
}

const retryAll = () => syncStore.retryAllErrors()
</script>
```

- [x] **Step 7: Verificar que no quedan llamadas viejas y que compila**

Run: `grep -rn "syncPendingPayments\|syncPendingVisits\|syncPendingExpenses\|removePending\|retryPayment(item.idempotency\|syncInitialData\|clearAll" resources/js/pwa`
Expected: sin resultados.

Run: `npm run build`
Expected: build OK, sin errores.

- [x] **Step 8: Correr el E2E (ahora debe pasar)**

Run: `npx playwright test tests/e2e/cola-offline.spec.js tests/e2e/sync-purge.spec.js --project=chromium`
Expected: PASS los dos.

- [x] **Step 9: Commit de las Tasks 10-12**

```bash
git add resources/js/pwa/stores/sync.js resources/js/pwa/stores/auth.js resources/js/pwa/App.vue resources/js/pwa/main.js resources/js/pwa/components/ui/SyncIndicator.vue resources/js/pwa/components/home/CollectorHome.vue resources/js/pwa/views/PaymentsHistoryView.vue resources/js/pwa/views/ProfileView.vue resources/js/pwa/views/SyncErrorsView.vue
git commit -F /home/jredondo/commit-msg.txt   # "fix(pwa): un solo motor de sincronización para las tres colas (PWA-001, 008, 011)"
```

---

### Task 13: Verificación completa

- [x] **Step 1: Comprobar que el E2E detecta el bug (ida y vuelta)**

En `resources/js/pwa/stores/sync.js`, cambiar temporalmente en `sincronizarCola`:
`const pendientes = await tabla.filter((x) => !x.permanent_error).toArray()`
por
`const pendientes = await tabla.where('permanent_error').equals(false).toArray()`

Run: `npm run build && npx playwright test tests/e2e/cola-offline.spec.js --project=chromium`
Expected: FAIL (la consulta por booleano rechaza). **Revertir el cambio**, `npm run build`, y volver a correr: PASS. Confirmar con `git diff resources/js/pwa/stores/sync.js` (vacío).

- [x] **Step 2: Suite PHP completa**

Run: `php artisan test`
Expected: PASS. Si falla algún test de la lista "flaky" del CHANGELOG (`PaymentMaterializerTest`, `DelinquencyMetricsService`, `CreditParentChildTest`, `ExtendWithInterestOperationTest`, `SnapshotDashboardMetricsTest`), correrlo aislado para confirmar que es el estado compartido de la BD dev y no este cambio.

- [x] **Step 3: Estilo y tipos**

Run: `vendor/bin/pint --dirty` y luego `vendor/bin/pint --test`
Expected: sin cambios pendientes.

Run: `vendor/bin/phpstan analyse --level=5 app/`
Expected: `[OK] No errors`. Si aparecen errores en archivos nuevos, corregirlos. No añadirlos al baseline.

- [x] **Step 4: E2E completo como en CI**

Run: `npx playwright test --project=chromium --project=webkit`
Expected: PASS todo (WebKit solo corre `creditos-listado-y-detalle`).

- [x] **Step 5: CHANGELOG**

En `CHANGELOG.md`, bajo `## [Sin publicar]` → `### Corregido`, añadir al principio:

```markdown
- **La cola offline por fin sube, y ningún cobro capturado en campo se pierde (PWA-001, auditoría 2026-09-24).** Desde abril de 2026 ningún pago, visita ni gasto guardado sin señal llegó al servidor: la sincronización consultaba `where('permanent_error').equals(true)` y IndexedDB no admite booleanos como clave, así que la promesa rechazaba siempre, aun con la cola vacía, y en pagos dejaba `syncing` trabado hasta recargar. En producción había 0 pagos, 0 visitas y 0 gastos venidos de la cola en toda la historia. Dexie v8 quita esos índices y un solo motor reemplaza las tres copias: lotes de 50/100, un fallo de red no penaliza, cada ítem responde por separado y la cola sube al entrar, al volver a primer plano, al volver la señal y cada 3 minutos. **Principio nuevo:** el servidor aplica el cobro o lo **retiene** en `held_payments` (sin tocar saldos) si llegó con más de 7 días, tiene fecha futura, apunta a un crédito que cambió, supera el saldo, lo capturó otro usuario del teléfono o el cobrador lo mandó a revisión; el admin lo aprueba o rechaza en **Cobros en revisión**, con pista de posibles duplicados. El teléfono solo suelta un ítem cuando el servidor lo tiene. También: montos de gastos y promesas escritos en modo simplificado ya no llegan mil veces menores (PWA-010); cambiar de usuario ya no borra la cola del anterior (se sube a su nombre; PWA-011); la pantalla de errores lee de IndexedDB, está enlazada, cambia "Descartar" por "Enviar a revisión" en pagos y ya no se queda en blanco por el locale `es_CO`; los fallos de la cola llegan a Sentry. **Al desplegar:** los teléfonos subirán lo atascado; revisar la bandeja con el admin.
```

- [x] **Step 6: Commit**

```bash
git add CHANGELOG.md
git commit -F /home/jredondo/commit-msg.txt   # "docs(changelog): arreglo de la cola offline"
```

---

### Task 14: PR (el merge y el despliegue, solo con el OK del usuario)

- [x] **Step 1: Push y PR**

```bash
git push -u origin fix/pwa-cola-offline
gh pr create --base main --title "fix(pwa): la cola offline sube y ningún cobro se pierde (PWA-001)" --body-file /home/jredondo/pr-body.md
```

El cuerpo (`/home/jredondo/pr-body.md`) resume: causa raíz y evidencia (reproducción Dexie en Chromium y WebKit; 0 ítems de cola en producción), el principio de retención, qué incluye (PWA-001, 010, 008 parcial, 011 punto 3), qué queda fuera (002, 003, jornada del supervisor, privacidad de 011), las pruebas (PHP nuevas y ajustadas, E2E que falla sin el arreglo) y el plan de despliegue. Termina con `🤖 Generated with [Claude Code](https://claude.com/claude-code)`.

- [x] **Step 2: Esperar el CI y reportar**

Si falla, diagnosticar desde los logs, corregir en la rama y volver a subir. **No mergear ni desplegar sin el OK explícito del usuario:** al desplegar, los teléfonos van a subir meses de cola atascada.

- [x] **Step 3: Con el OK — merge, realinear `dev` y desplegar**

```bash
gh api -X PUT repos/jdredondo/credify/pulls/<N>/merge -f merge_method=squash
# en /var/www/html/credify:
git fetch origin && git checkout main && git pull origin main
git branch -f dev main && git push origin dev --force-with-lease
npm run build && bash /home/jredondo/deploy.sh
```

Verificar: el md5 del bundle local es igual al de producción, `php artisan migrate:status` en producción muestra `create_held_payments_table` en Ran, y `/admin` muestra "Cobros en revisión".
