# Señal débil (PWA-002) — Plan de implementación

> **Nota:** este plan es histórico. Las revisiones de calidad y la revisión final de la rama refinaron varios puntos (marca de envío con tope, copia de los avisos, liberación de claves, aviso de cobros en cola, entre otros), y el texto de abajo ya no los refleja todos. **La fuente de verdad es el spec** (`docs/superpowers/specs/2026-09-28-senal-debil-design.md`), que se mantiene alineado con el código.

> **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 una respuesta perdida no pierda ni duplique nada: cobro, visita "No paga" y gasto quedan en la cola antes de salir y solo se sueltan cuando el servidor responde; crédito y movimiento de socio llevan una clave que el servidor deduplica.

**Architecture:** En la PWA, una sola función del store de sync (`enviarOEncolar`) guarda la fila en IndexedDB con una marca de envío, hace el POST directo con la misma clave y un timeout de 10 s, y según la respuesta suelta la fila o la deja para el motor de PWA-001, que salta las filas con marca vigente. En el servidor, el gasto con conexión deduplica por la clave que ya tenía columna, y crédito y socio suman `idempotency_key` con índice único por empresa, con el chequeo de duplicado antes del límite del plan y del saldo.

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

**Spec:** `docs/superpowers/specs/2026-09-28-senal-debil-design.md` (leerla antes de empezar).

---

## Antes de empezar

- Trabajar en el worktree `/home/jredondo/credify-d`, rama `fix/pwa-senal-debil`. Todo comando corre **dentro de WSL** (`wsl bash -lc "cd /home/jredondo/credify-d && …"` 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/` (instalados de verdad, no symlinks; no crearlos como symlinks nunca: los tests correrían contra el código de otro checkout sin avisar).
- Los tests PHP comparten la BD dev `credify` y usan `DatabaseTransactions`. Todo archivo de test nuevo lleva el trait. **Ningún test corre migraciones**: un DDL hace commit implícito y rompería el aislamiento.
- `git add` siempre con rutas explícitas. **Nunca** agregar `public/css/filament/*` ni `public/js/filament/*` (los regenera `composer install` y ensucian el árbol). Nunca `git add -A`, nunca `git stash` a secas.
- 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").
- **E2E:** crear una vez este script y usarlo para correr Playwright. Si el puerto 8123 está ocupado, Playwright **reutiliza** ese servidor (`reuseExistingServer`) y los tests correrían contra el código de otro checkout:

```bash
cat > /home/jredondo/e2e-senal.sh <<'EOF'
#!/bin/bash
set -uo pipefail
cd /home/jredondo/credify-d
if ss -ltn | grep -q ':8123 '; then
  echo "El puerto 8123 está ocupado: Playwright reutilizaría ese servidor (otro checkout). Detenerlo antes."
  exit 1
fi
npx playwright test "$@" --project=chromium
EOF
chmod +x /home/jredondo/e2e-senal.sh
```

  Uso: `bash /home/jredondo/e2e-senal.sh tests/e2e/senal-debil.spec.js`. El `webServer` de Playwright hace `npm run build` y levanta `artisan serve` con el código del worktree. Cada corrida gasta un login y el login admite 5 por minuto por usuario: no encadenar más de cuatro corridas seguidas.

## Mapa de archivos

**Servidor — nuevos**
- `database/migrations/2026_09_28_120000_add_idempotency_key_to_credits_and_partner_movements.php` — columna + índice único en `credits`, `capital_contributions`, `partner_withdrawals`.
- Tests: `tests/Feature/IdempotencyKeyColumnsTest.php`, `tests/Feature/Pwa/ExpenseIdempotencyTest.php`, `tests/Feature/Pwa/CreditIdempotencyTest.php`, `tests/Feature/Pwa/PartnerTransactionIdempotencyTest.php`.

**Servidor — modificados**
- `app/Models/Credit.php`, `app/Models/CapitalContribution.php`, `app/Models/PartnerWithdrawal.php` — `idempotency_key` en `$fillable`.
- `app/Http/Controllers/Api/Pwa/ExpenseController.php` — deduplica por clave.
- `app/Http/Requests/Pwa/StoreCreditRequest.php` — valida la clave.
- `app/Http/Controllers/Api/Pwa/CreditController.php` — deduplica antes del límite del plan.
- `app/Services/CreditOperationService.php` — guarda la clave al crear.
- `app/Http/Controllers/Api/Pwa/PartnerController.php` — deduplica antes del saldo.

**PWA — nuevos**
- `resources/js/pwa/utils/transporte.js` — `esFalloDeTransporte`, compartida por el motor y los formularios.
- `resources/js/pwa/utils/claveOperacion.js` — clave de crédito / socio en `sessionStorage` hasta que el servidor responda.
- E2E: `tests/e2e/senal-debil.spec.js`.

**PWA — modificados**
- `resources/js/pwa/stores/sync.js` — `enviarOEncolar`, marca de envío en el motor, fuera `queuePayment`/`queueVisit`/`queueExpense`.
- `resources/js/pwa/views/PaymentView.vue`, `resources/js/pwa/views/PaymentSuccessView.vue`
- `resources/js/pwa/components/collections/NoPaymentModal.vue`
- `resources/js/pwa/views/ExpenseCreateView.vue`
- `resources/js/pwa/views/CreditCreateView.vue`, `resources/js/pwa/views/PartnerTransactionView.vue`

**Docs**
- `CHANGELOG.md`, y el estado del spec.

---

### Task 1: Columnas de clave en crédito, aporte y retiro

**Files:**
- Create: `database/migrations/2026_09_28_120000_add_idempotency_key_to_credits_and_partner_movements.php`
- Modify: `app/Models/Credit.php` (`$fillable`, líneas 71-88), `app/Models/CapitalContribution.php` (`$fillable`, líneas 41-55), `app/Models/PartnerWithdrawal.php` (`$fillable`, líneas 48-66)
- Test: `tests/Feature/IdempotencyKeyColumnsTest.php`

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature;

use App\Models\Company;
use App\Models\Credit;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;

/**
 * PWA-002: crédito, aporte y retiro guardan la clave de idempotencia del teléfono,
 * única por empresa. Solo se mira el esquema ya migrado: la base de los tests es
 * la de desarrollo y un DDL hace commit implícito.
 */
class IdempotencyKeyColumnsTest extends TestCase
{
    use DatabaseTransactions;

    private const INDICES = [
        'credits' => 'credits_company_idempotency_unique',
        'capital_contributions' => 'capital_contributions_company_idempotency_unique',
        'partner_withdrawals' => 'partner_withdrawals_company_idempotency_unique',
    ];

    #[Test]
    public function las_tres_tablas_tienen_la_columna_y_su_indice_unico_por_empresa(): void
    {
        foreach (self::INDICES as $tabla => $nombre) {
            $this->assertTrue(Schema::hasColumn($tabla, 'idempotency_key'), "{$tabla} sin idempotency_key");

            $indice = collect(Schema::getIndexes($tabla))->firstWhere('name', $nombre);
            $this->assertNotNull($indice, "{$tabla} sin el índice {$nombre}");
            $this->assertTrue($indice['unique'], "{$nombre} no es único");
            $this->assertSame(['company_id', 'idempotency_key'], $indice['columns']);
        }
    }

    #[Test]
    public function el_indice_de_creditos_rechaza_la_misma_clave_en_la_empresa(): void
    {
        $empresa = Company::factory()->create();
        $clave = (string) Str::uuid();
        Credit::factory()->create(['company_id' => $empresa->id, 'idempotency_key' => $clave]);

        $this->expectException(UniqueConstraintViolationException::class);

        Credit::factory()->create(['company_id' => $empresa->id, 'idempotency_key' => $clave]);
    }

    #[Test]
    public function la_misma_clave_en_otra_empresa_no_choca(): void
    {
        $clave = (string) Str::uuid();
        Credit::factory()->create(['company_id' => Company::factory()->create()->id, 'idempotency_key' => $clave]);
        Credit::factory()->create(['company_id' => Company::factory()->create()->id, 'idempotency_key' => $clave]);

        $this->assertSame(2, Credit::withoutGlobalScopes()->where('idempotency_key', $clave)->count());
    }
}
```

- [ ] **Step 2: Correr el test y ver que falla**

Run: `php artisan test tests/Feature/IdempotencyKeyColumnsTest.php`
Expected: FAIL — `credits sin idempotency_key` (y los otros dos también fallan: la columna no existe).

- [ ] **Step 3: Escribir la migración**

```php
<?php

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

/**
 * PWA-002: crédito, aporte y retiro de socio guardan la clave de idempotencia
 * del teléfono. Si la respuesta se pierde y el usuario reintenta, el servidor
 * devuelve el original en vez de crear otro (con otro desembolso).
 *
 * Nullable: Filament y los bundles viejos no la mandan, y el índice único por
 * empresa admite muchos NULL.
 */
return new class extends Migration
{
    /** @var array<string, string> tabla => nombre del índice */
    private const TABLAS = [
        'credits' => 'credits_company_idempotency_unique',
        'capital_contributions' => 'capital_contributions_company_idempotency_unique',
        'partner_withdrawals' => 'partner_withdrawals_company_idempotency_unique',
    ];

    public function up(): void
    {
        foreach (self::TABLAS as $tabla => $indice) {
            Schema::table($tabla, function (Blueprint $table) use ($indice) {
                $table->uuid('idempotency_key')->nullable();
                $table->unique(['company_id', 'idempotency_key'], $indice);
            });
        }
    }

    public function down(): void
    {
        foreach (self::TABLAS as $tabla => $indice) {
            Schema::table($tabla, function (Blueprint $table) use ($indice) {
                $table->dropUnique($indice);
                $table->dropColumn('idempotency_key');
            });
        }
    }
};
```

- [ ] **Step 4: Sumar la columna a `$fillable` en los tres modelos**

En `app/Models/Credit.php`, después de `'due_day_2',` dentro de `$fillable`:

```php
        'due_day_2',
        'idempotency_key',
    ];
```

En `app/Models/CapitalContribution.php`, después de `'locked_at',` dentro de `$fillable`:

```php
        'locked_at',
        'idempotency_key',
    ];
```

En `app/Models/PartnerWithdrawal.php`, después de `'locked_at',` dentro de `$fillable`:

```php
        'locked_at',
        'idempotency_key',
    ];
```

- [ ] **Step 5: Migrar la BD dev y correr el test**

Run: `php artisan migrate` (aplica solo la migración nueva sobre la BD dev `credify`; columnas nullable, no molesta a los otros checkouts).
Run: `php artisan test tests/Feature/IdempotencyKeyColumnsTest.php`
Expected: PASS (3 tests).

- [ ] **Step 6: Pint y commit**

Run: `./vendor/bin/pint database/migrations/2026_09_28_120000_add_idempotency_key_to_credits_and_partner_movements.php app/Models/Credit.php app/Models/CapitalContribution.php app/Models/PartnerWithdrawal.php tests/Feature/IdempotencyKeyColumnsTest.php`

```bash
git add database/migrations/2026_09_28_120000_add_idempotency_key_to_credits_and_partner_movements.php app/Models/Credit.php app/Models/CapitalContribution.php app/Models/PartnerWithdrawal.php tests/Feature/IdempotencyKeyColumnsTest.php
git commit -F /home/jredondo/commit-msg.txt
```

Mensaje: `feat(db): clave de idempotencia en créditos, aportes y retiros de socio (PWA-002)` + una línea explicando que es nullable con índice único por empresa + Co-Authored-By.

---

### Task 2: El gasto con conexión deduplica por clave

**Files:**
- Modify: `app/Http/Controllers/Api/Pwa/ExpenseController.php` (imports; `store()`, líneas 59-115)
- Test: `tests/Feature/Pwa/ExpenseIdempotencyTest.php`

Contexto: `expenses` ya tiene la columna y el índice único `expenses_company_idempotency_unique` (migración `2026_04_04_000003`); solo el lote los usaba. `FinanceAutoLogger::logExpense()` ya acepta `idempotency_key` y no abre transacción propia. Laravel lanza `Illuminate\Database\UniqueConstraintViolationException` (subclase de `QueryException`) cuando MySQL responde 1062.

- [ ] **Step 1: Escribir los tests que fallan**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Pwa;

use App\Models\Company;
use App\Models\Expense;
use App\Models\Plan;
use App\Models\Subscription;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Laravel\Sanctum\Sanctum;
use PHPUnit\Framework\Attributes\Test;
use Spatie\Permission\Models\Role;
use Tests\TestCase;

/**
 * PWA-002: el gasto con conexión lleva clave de idempotencia. Si la respuesta se
 * pierde y el teléfono lo reintenta (o lo sube el lote), no se crea otro.
 */
class ExpenseIdempotencyTest extends TestCase
{
    use DatabaseTransactions;

    private Company $company;

    private User $collector;

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

        Role::firstOrCreate(['name' => 'collector', 'guard_name' => 'web']);
        $plan = Plan::factory()->create(['has_pwa_access' => true]);
        $this->company = Company::factory()->create();
        Subscription::factory()->active()->create([
            'company_id' => $this->company->id,
            'plan_id' => $plan->id,
            'ends_at' => now()->addYear(),
        ]);
        $this->collector = User::factory()->create(['company_id' => $this->company->id]);
        $this->collector->assignRole('collector');
        Sanctum::actingAs($this->collector->fresh());
    }

    /** @return array<string, mixed> */
    private function gasto(array $extra = []): array
    {
        return array_merge([
            'amount' => 25000,
            'category' => 'transportation',
            'notes' => 'Combustible visita',
        ], $extra);
    }

    private function gastosDeLaEmpresa(?string $clave = null): int
    {
        return Expense::withoutGlobalScopes()
            ->where('company_id', $this->company->id)
            ->when($clave !== null, fn ($q) => $q->where('idempotency_key', $clave))
            ->count();
    }

    /** Un gasto insertado directo, sin hooks: lo que dejó "el otro" envío con la misma clave. */
    private function insertarGasto(int $companyId, int $userId, string $clave): int
    {
        return DB::table('expenses')->insertGetId([
            'company_id' => $companyId,
            'user_id' => $userId,
            'amount' => '25000.00',
            'category' => 'transportation',
            'expense_type' => 'operational',
            'affects_cash' => true,
            'affects_profit' => true,
            'reduces_capital' => false,
            'is_locked' => false,
            'requires_approval' => true,
            'approval_status' => 'pending',
            'operation_date' => now()->toDateString(),
            'idempotency_key' => $clave,
            'created_at' => now(),
            'updated_at' => now(),
        ]);
    }

    #[Test]
    public function la_misma_clave_dos_veces_crea_un_solo_gasto(): void
    {
        $clave = (string) Str::uuid();

        $primero = $this->postJson('/api/pwa/expenses', $this->gasto(['idempotency_key' => $clave]))
            ->assertCreated();

        $this->postJson('/api/pwa/expenses', $this->gasto(['idempotency_key' => $clave]))
            ->assertOk()
            ->assertJsonPath('duplicate', true)
            ->assertJsonPath('expense.id', $primero->json('expense.id'));

        $this->assertSame(1, $this->gastosDeLaEmpresa($clave));
    }

    #[Test]
    public function sin_clave_cada_envio_crea_un_gasto_como_hasta_ahora(): void
    {
        $antes = $this->gastosDeLaEmpresa();

        $this->postJson('/api/pwa/expenses', $this->gasto())->assertCreated();
        $this->postJson('/api/pwa/expenses', $this->gasto())->assertCreated();

        $this->assertSame($antes + 2, $this->gastosDeLaEmpresa());
    }

    #[Test]
    public function la_misma_clave_en_otra_empresa_no_choca(): void
    {
        $clave = (string) Str::uuid();
        $otra = Company::factory()->create();
        $this->insertarGasto($otra->id, User::factory()->create(['company_id' => $otra->id])->id, $clave);

        $this->postJson('/api/pwa/expenses', $this->gasto(['idempotency_key' => $clave]))
            ->assertCreated();

        $this->assertSame(1, $this->gastosDeLaEmpresa($clave));
    }

    #[Test]
    public function una_clave_que_no_es_uuid_se_rechaza(): void
    {
        $this->postJson('/api/pwa/expenses', $this->gasto(['idempotency_key' => 'no-es-uuid']))
            ->assertStatus(422)
            ->assertJsonValidationErrors(['idempotency_key']);
    }

    #[Test]
    public function en_una_carrera_el_indice_frena_al_segundo_y_se_devuelve_el_original(): void
    {
        $clave = (string) Str::uuid();
        $original = null;

        // El otro envío con la misma clave gana entre la búsqueda y el insert: se
        // simula metiendo su gasto justo antes de que se cree este.
        Expense::creating(function (Expense $gasto) use ($clave, &$original): void {
            if ($original === null && $gasto->idempotency_key === $clave) {
                $original = $this->insertarGasto($this->company->id, $this->collector->id, $clave);
            }
        });

        $this->postJson('/api/pwa/expenses', $this->gasto(['idempotency_key' => $clave]))
            ->assertOk()
            ->assertJsonPath('duplicate', true)
            ->assertJsonPath('expense.id', $original);

        $this->assertSame(1, $this->gastosDeLaEmpresa($clave));
    }
}
```

- [ ] **Step 2: Correr los tests y ver que fallan**

Run: `php artisan test tests/Feature/Pwa/ExpenseIdempotencyTest.php`
Expected: FAIL `la_misma_clave_dos_veces…` (el segundo POST da 201, no 200), `una_clave_que_no_es_uuid…` (201 en vez de 422) y la carrera (500 por el índice único). Los otros dos pasan.

- [ ] **Step 3: Implementar**

Imports de `ExpenseController.php` (sumar los dos `use` que faltan):

```php
use App\Enums\ExpenseCategory;
use App\Http\Controllers\Api\Pwa\Traits\RoleAwareQueries;
use App\Http\Controllers\Controller;
use App\Models\CompanyFinancialSettings;
use App\Models\Expense;
use App\Services\FinanceAutoLogger;
use Carbon\Carbon;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
```

Reemplazar `store()` completo (líneas 59-115) por:

```php
    public function store(Request $request): JsonResponse
    {
        $user = $request->user();

        if ($request->boolean('is_simplified_amount') && $request->filled('amount')) {
            $settings = CompanyFinancialSettings::forCompany($user->company_id);
            if ($settings->isSimplifiedInputEnabled()) {
                $request->merge(['amount' => $settings->convertSimplifiedToReal((float) $request->amount)]);
            }
        }

        $request->validate([
            'amount' => ['required', 'numeric', 'min:0.01', 'max:9999999.99'],
            'category' => ['required', 'string', 'in:'.implode(',', self::ALLOWED_CATEGORIES)],
            'notes' => ['nullable', 'string', 'max:1000'],
            'date' => ['nullable', 'date', 'before_or_equal:'.Carbon::today()->toDateString()],
            'is_simplified_amount' => ['nullable', 'boolean'],
            'simplified_amount_confirmed' => ['nullable', 'boolean'],
            'idempotency_key' => ['nullable', 'uuid'],
        ]);

        $companyId = (int) $user->company_id;
        $clave = $request->input('idempotency_key');

        // PWA-002: la respuesta de un envío anterior con esta clave se perdió y el
        // teléfono lo reintenta (o lo sube el lote). El gasto ya existe: se
        // devuelve en vez de crear otro.
        if (is_string($clave) && ($existente = $this->gastoConClave($companyId, $clave))) {
            return $this->gastoDuplicado($existente);
        }

        $category = ExpenseCategory::from($request->category);
        $opDate = $request->filled('date')
            ? Carbon::parse($request->date)->toDateString()
            : Carbon::today()->toDateString();

        try {
            $expense = $this->logger->logExpense([
                'company_id' => $companyId,
                'user_id' => $user->id,
                'amount' => (float) $request->amount,
                'category' => $category->value,
                'operation_date' => $opDate,
                'notes' => $request->notes,
                'idempotency_key' => $clave,
            ]);
        } catch (UniqueConstraintViolationException $e) {
            // Carrera: otro envío con la misma clave se creó entre la búsqueda y
            // este insert, y el índice único frenó a este. El gasto existe.
            $existente = is_string($clave) ? $this->gastoConClave($companyId, $clave) : null;
            if (! $existente) {
                throw $e;
            }

            return $this->gastoDuplicado($existente);
        }

        // Flujo de aprobación según rol (fuente única en el modelo Expense):
        // admin → auto-aprobado; supervisor/collector → pendiente de aprobación.
        $expense->applyApprovalPolicyForCreator($user);
        $expense->save();

        return response()->json([
            'message' => $expense->requires_approval
                ? 'Gasto registrado. Queda pendiente de aprobación.'
                : 'Gasto registrado y aprobado.',
            'expense' => $this->formatoGasto($expense),
        ], 201);
    }

    private function gastoConClave(int $companyId, string $clave): ?Expense
    {
        return Expense::withoutGlobalScopes()
            ->where('company_id', $companyId)
            ->where('idempotency_key', $clave)
            ->first();
    }

    private function gastoDuplicado(Expense $expense): JsonResponse
    {
        return response()->json([
            'message' => 'Este gasto ya fue registrado.',
            'duplicate' => true,
            'expense' => $this->formatoGasto($expense),
        ]);
    }

    /** @return array<string, mixed> */
    private function formatoGasto(Expense $expense): array
    {
        $category = $expense->getRawOriginal('category');

        return [
            'id' => $expense->id,
            'category' => $category,
            'category_label' => ExpenseCategory::from($category)->getLabel(),
            'amount' => (float) $expense->amount,
            'date' => $expense->operation_date->toDateString(),
            'notes' => $expense->notes,
            'approval_status' => $expense->approval_status,
            'requires_approval' => $expense->requires_approval,
        ];
    }
```

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

Run: `php artisan test tests/Feature/Pwa/ExpenseIdempotencyTest.php tests/Feature/Pwa/ExpenseApprovalCashGatingTest.php tests/Feature/Pwa/SimplifiedInputExpandTest.php`
Expected: PASS (los nuevos y los que ya cubrían este endpoint).

- [ ] **Step 5: PHPStan, Pint y commit**

Run: `./vendor/bin/phpstan analyse --level=5 app/ --no-progress` → `[OK] No errors`.
Run: `./vendor/bin/pint app/Http/Controllers/Api/Pwa/ExpenseController.php tests/Feature/Pwa/ExpenseIdempotencyTest.php`

```bash
git add app/Http/Controllers/Api/Pwa/ExpenseController.php tests/Feature/Pwa/ExpenseIdempotencyTest.php
git commit -F /home/jredondo/commit-msg.txt
```

Mensaje: `fix(pwa): el gasto con conexión deduplica por su clave (PWA-002)` + por qué (la columna y el índice existían, el endpoint los ignoraba) + Co-Authored-By.

---

### Task 3: El crédito deduplica por clave, antes del límite del plan

**Files:**
- Modify: `app/Http/Requests/Pwa/StoreCreditRequest.php` (`rules()` y `messages()`)
- Modify: `app/Http/Controllers/Api/Pwa/CreditController.php` (imports; `store()`, líneas 267-372)
- Modify: `app/Services/CreditOperationService.php` (`createNewCredit()`, `Credit::create`, líneas 67-84)
- Test: `tests/Feature/Pwa/CreditIdempotencyTest.php`

Contexto: `createNewCredit()` crea crédito, cuotas, gasto de desembolso y operación financiera en un `DB::transaction`, y `PlanLimitService::runGuardedCreate()` lo envuelve en otra: si el insert del crédito choca con el índice único, todo vuelve atrás. El mínimo de monto por defecto de una empresa es $100.000 (`min_credit_amount`), máximo de tasa 30 %, máximo de cuotas 60.

- [ ] **Step 1: Escribir los tests que fallan**

```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\Expense;
use App\Models\FinancialOperation;
use App\Models\Plan;
use App\Models\Subscription;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Str;
use Laravel\Sanctum\Sanctum;
use PHPUnit\Framework\Attributes\Test;
use Spatie\Permission\Models\Role;
use Tests\TestCase;

/**
 * PWA-002: un crédito cuya respuesta se perdió no se crea dos veces al
 * reintentar. Un crédito doble es un desembolso doble.
 */
class CreditIdempotencyTest extends TestCase
{
    use DatabaseTransactions;

    private Company $company;

    private Client $client;

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

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

        [$this->company, $admin, $this->client] = $this->empresaConAdmin(maxCreditosActivos: 0);
        Sanctum::actingAs($admin->fresh());
    }

    /**
     * @param  int  $maxCreditosActivos  0 = sin límite
     * @return array{0: Company, 1: User, 2: Client}
     */
    private function empresaConAdmin(int $maxCreditosActivos): array
    {
        $empresa = Company::factory()->create();
        $plan = Plan::factory()->create([
            'has_pwa_access' => true,
            'max_active_credits' => $maxCreditosActivos,
            'max_monthly_volume' => 0,
        ]);
        Subscription::factory()->active()->create([
            'company_id' => $empresa->id,
            'plan_id' => $plan->id,
            'ends_at' => now()->addYear(),
        ]);
        $admin = User::factory()->create(['company_id' => $empresa->id]);
        $admin->assignRole('admin');

        return [$empresa, $admin, Client::factory()->create(['company_id' => $empresa->id])];
    }

    /** @return array<string, mixed> */
    private function credito(Client $cliente, array $extra = []): array
    {
        return array_merge([
            'client_id' => $cliente->id,
            'amount' => 200000,
            'interest_rate' => 5,
            'installments_count' => 4,
            'periodicity' => 'monthly',
            'start_date' => now()->addDay()->toDateString(),
        ], $extra);
    }

    private function creditosConClave(string $clave): int
    {
        return Credit::withoutGlobalScopes()->where('idempotency_key', $clave)->count();
    }

    #[Test]
    public function la_misma_clave_dos_veces_crea_un_credito_un_desembolso_y_una_operacion(): void
    {
        $clave = (string) Str::uuid();

        $id = $this->postJson('/api/pwa/credits', $this->credito($this->client, ['idempotency_key' => $clave]))
            ->assertCreated()
            ->assertJsonPath('duplicate', false)
            ->json('credit.id');

        $this->postJson('/api/pwa/credits', $this->credito($this->client, ['idempotency_key' => $clave]))
            ->assertOk()
            ->assertJsonPath('duplicate', true)
            ->assertJsonPath('credit.id', $id);

        $this->assertSame(1, $this->creditosConClave($clave));
        $this->assertSame(1, Expense::withoutGlobalScopes()
            ->where('credit_id', $id)->where('category', 'disbursement')->count());
        $this->assertSame(1, FinancialOperation::withoutGlobalScopes()
            ->where('target_credit_id', $id)->where('operation_type', 'credit_disbursement')->count());
    }

    #[Test]
    public function el_reintento_con_el_cupo_lleno_devuelve_el_original(): void
    {
        [, $admin, $cliente] = $this->empresaConAdmin(maxCreditosActivos: 1);
        Sanctum::actingAs($admin->fresh());
        $clave = (string) Str::uuid();

        $id = $this->postJson('/api/pwa/credits', $this->credito($cliente, ['idempotency_key' => $clave]))
            ->assertCreated()
            ->json('credit.id');

        // El cupo lo llenó este mismo crédito: su reintento no puede rechazarse por eso.
        $this->postJson('/api/pwa/credits', $this->credito($cliente, ['idempotency_key' => $clave]))
            ->assertOk()
            ->assertJsonPath('duplicate', true)
            ->assertJsonPath('credit.id', $id);

        // Un crédito nuevo, con otra clave, sí choca con el cupo.
        $this->postJson('/api/pwa/credits', $this->credito($cliente, ['idempotency_key' => (string) Str::uuid()]))
            ->assertStatus(422);
    }

    #[Test]
    public function sin_clave_cada_envio_crea_un_credito_como_hasta_ahora(): void
    {
        $this->postJson('/api/pwa/credits', $this->credito($this->client))->assertCreated();
        $this->postJson('/api/pwa/credits', $this->credito($this->client))->assertCreated();

        $this->assertSame(2, Credit::withoutGlobalScopes()->where('client_id', $this->client->id)->count());
    }

    #[Test]
    public function la_misma_clave_en_otra_empresa_no_choca(): void
    {
        $clave = (string) Str::uuid();
        Credit::factory()->create(['company_id' => Company::factory()->create()->id, 'idempotency_key' => $clave]);

        $this->postJson('/api/pwa/credits', $this->credito($this->client, ['idempotency_key' => $clave]))
            ->assertCreated()
            ->assertJsonPath('duplicate', false);

        $this->assertSame(2, $this->creditosConClave($clave));
    }

    #[Test]
    public function una_clave_que_no_es_uuid_se_rechaza(): void
    {
        $this->postJson('/api/pwa/credits', $this->credito($this->client, ['idempotency_key' => 'no-es-uuid']))
            ->assertStatus(422)
            ->assertJsonValidationErrors(['idempotency_key']);
    }
}
```

- [ ] **Step 2: Correr los tests y ver que fallan**

Run: `php artisan test tests/Feature/Pwa/CreditIdempotencyTest.php`
Expected: FAIL `la_misma_clave_dos_veces…` (el primero no trae `duplicate` y el segundo crea otro), `el_reintento_con_el_cupo_lleno…` (422), `la_misma_clave_en_otra_empresa…` (sin `duplicate`) y `una_clave_que_no_es_uuid…` (201). `sin_clave…` pasa.

- [ ] **Step 3: Validar la clave en `StoreCreditRequest`**

En `rules()`, después de `'add_to_route' => ['nullable', 'boolean'],`:

```php
            'add_to_route' => ['nullable', 'boolean'],
            'idempotency_key' => ['nullable', 'uuid'],
        ];
```

En `messages()`, después de `'collector_user_id.exists' => …,`:

```php
            'collector_user_id.exists' => 'El cobrador no existe o no pertenece a tu empresa.',
            'idempotency_key.uuid' => 'La clave de idempotencia debe ser un UUID válido.',
        ];
```

- [ ] **Step 4: Guardar la clave al crear, en `CreditOperationService::createNewCredit()`**

Dentro de `Credit::create([...])`, después de `'due_day_2' => $data['due_day_2'] ?? null,`:

```php
                'due_day_2' => $data['due_day_2'] ?? null,
                'idempotency_key' => $data['idempotency_key'] ?? null,
            ]);
```

- [ ] **Step 5: Deduplicar en `CreditController::store()`**

Sumar el import:

```php
use Illuminate\Database\UniqueConstraintViolationException;
```

Reemplazar `store()` completo (líneas 267-372) por:

```php
    public function store(StoreCreditRequest $request): JsonResponse
    {
        // Verificar permiso
        if (! $this->canCreateCredits($request)) {
            return response()->json([
                'message' => 'No tienes permiso para crear créditos.',
            ], 403);
        }

        $user = $request->user();
        $clave = $request->input('idempotency_key');

        // PWA-002: un reintento de un crédito que ya se creó (la respuesta se
        // perdió). Se devuelve el original ANTES del límite del plan: si no, ese
        // mismo crédito podría hacer rechazar su reintento por "cupo alcanzado".
        if (is_string($clave) && ($existente = $this->creditoConClave((int) $user->company_id, $clave))) {
            return $this->respuestaCredito($existente, duplicado: true);
        }

        $company = Company::find($user->company_id);

        // Si no se especifica collector, asignar al usuario actual si es collector
        $collectorUserId = $request->collector_user_id;
        if (! $collectorUserId) {
            $role = $this->getUserPwaRole($request);
            if ($role === 'collector') {
                $collectorUserId = $user->id;
            }
        }

        try {
            $creditService = app(CreditOperationService::class);

            $creditData = [
                'company_id' => $user->company_id,
                'client_id' => $request->client_id,
                'collector_user_id' => $collectorUserId,
                'amount' => (float) $request->amount,
                'interest_rate' => (float) $request->interest_rate,
                'installments_count' => (int) $request->installments_count,
                'periodicity' => $request->periodicity,
                'start_date' => $request->start_date,
                'first_due_date_override' => $request->first_due_date_override,
                'due_day_1' => $request->due_day_1,
                'due_day_2' => $request->due_day_2,
                'idempotency_key' => $clave,
            ];

            // Límite del plan (créditos activos + volumen mensual): el chequeo y la
            // creación van juntos, bajo lock por empresa, para que dos syncs concurrentes
            // no rebasen el cupo (#169). Bloqueo estricto, igual que en el panel.
            $service = app(PlanLimitService::class);
            $credit = $company
                ? $service->runGuardedCreate($company, function () use ($service, $company, $request, $creditService, $creditData) {
                    $service->assertCanCreateCredit($company, (float) $request->amount);

                    return $creditService->createNewCredit($creditData);
                })
                : $creditService->createNewCredit($creditData);

            Log::info('PWA Credit: Created', [
                'credit_id' => $credit->id,
                'client_id' => $credit->client_id,
                'amount' => $credit->amount,
                'user_id' => $user->id,
            ]);

            // Agregar a la ruta del collector si se solicita
            if ($request->boolean('add_to_route') && $collectorUserId) {
                app(CollectorCreditOrderService::class)->appendCredit(
                    $user->company_id,
                    $collectorUserId,
                    $credit->id,
                );
            }

            return $this->respuestaCredito($credit, duplicado: false);

        } catch (PlanLimitExceededException $e) {
            return response()->json(['message' => $e->getMessage()], 422);
        } catch (UniqueConstraintViolationException $e) {
            // Carrera: dos envíos con la misma clave pasaron la búsqueda. El índice
            // único frenó a este y su transacción entera (cuotas, desembolso y
            // operación financiera) volvió atrás: el crédito es el del otro envío.
            $existente = is_string($clave) ? $this->creditoConClave((int) $user->company_id, $clave) : null;
            if ($existente) {
                return $this->respuestaCredito($existente, duplicado: true);
            }

            return $this->errorAlCrearCredito($request, $e);
        } catch (\Exception $e) {
            return $this->errorAlCrearCredito($request, $e);
        }
    }

    private function creditoConClave(int $companyId, string $clave): ?Credit
    {
        return Credit::query()
            ->where('company_id', $companyId)
            ->where('idempotency_key', $clave)
            ->first();
    }

    /** El mismo cuerpo para un crédito recién creado y para uno que ya existía (duplicado). */
    private function respuestaCredito(Credit $credit, bool $duplicado): JsonResponse
    {
        $credit->load(['client:id,name', 'installments']);

        return response()->json([
            'message' => $duplicado ? 'Este crédito ya estaba creado.' : 'Crédito creado correctamente.',
            'duplicate' => $duplicado,
            'credit' => [
                'id' => $credit->id,
                'code' => $credit->code,
                'company_credit_number' => $credit->company_credit_number,
                'client' => [
                    'id' => $credit->client->id,
                    'name' => $credit->client->name,
                ],
                'amount' => round((float) $credit->amount, 2),
                'interest_rate' => round((float) $credit->interest_rate, 2),
                'total_receivable' => round((float) $credit->total_receivable, 2),
                'installments_count' => $credit->installments->count(),
                'installment_amount' => round((float) $credit->installments->first()?->total_amount, 2),
                'periodicity' => $credit->periodicity,
                'start_date' => $credit->start_date->toDateString(),
                'first_due_date' => $credit->installments->first()?->due_date->toDateString(),
            ],
        ], $duplicado ? 200 : 201);
    }

    private function errorAlCrearCredito(StoreCreditRequest $request, \Throwable $e): JsonResponse
    {
        Log::error('PWA Credit: Error', [
            'client_id' => $request->client_id,
            'amount' => $request->amount,
            'user_id' => $request->user()->id,
            'error' => $e->getMessage(),
        ]);

        return response()->json([
            'message' => 'Error al crear el crédito. Intenta de nuevo.',
        ], 500);
    }
```

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

Run: `php artisan test tests/Feature/Pwa/CreditIdempotencyTest.php tests/Feature/Security/StoreCreditCollectorRoleTest.php tests/Feature/Pwa/PrimeraCuotaPreviewTest.php`
Expected: PASS.

Run: `php artisan test --filter=Credit` (red más amplia sobre créditos).
Expected: PASS (salvo los flaky ya conocidos del CHANGELOG, que pasan aislados; si aparece uno, correrlo aislado para confirmarlo).

- [ ] **Step 7: PHPStan, Pint y commit**

Run: `./vendor/bin/phpstan analyse --level=5 app/ --no-progress` → `[OK] No errors`.
Run: `./vendor/bin/pint app/Http/Requests/Pwa/StoreCreditRequest.php app/Http/Controllers/Api/Pwa/CreditController.php app/Services/CreditOperationService.php tests/Feature/Pwa/CreditIdempotencyTest.php`

```bash
git add app/Http/Requests/Pwa/StoreCreditRequest.php app/Http/Controllers/Api/Pwa/CreditController.php app/Services/CreditOperationService.php tests/Feature/Pwa/CreditIdempotencyTest.php
git commit -F /home/jredondo/commit-msg.txt
```

Mensaje: `fix(pwa): un crédito cuya respuesta se pierde no se crea dos veces (PWA-002)` + por qué el chequeo va antes del límite del plan + Co-Authored-By.

---

### Task 4: El movimiento de socio deduplica por clave, antes del saldo

**Files:**
- Modify: `app/Http/Controllers/Api/Pwa/PartnerController.php` (imports; `storeTransaction()`, líneas 75-145; `createContribution()` y `createWithdrawal()`, líneas 151-229)
- Test: `tests/Feature/Pwa/PartnerTransactionIdempotencyTest.php`

Contexto: no hay `PartnerFactory`; un socio mínimo es `Partner::create(['company_id', 'name', 'joined_at'])`. `type` está casteado a `ContributionType` / `WithdrawalType` en los modelos. El retiro crea además un gasto contable en la misma transacción.

- [ ] **Step 1: Escribir los tests que fallan**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Pwa;

use App\Models\CapitalContribution;
use App\Models\Company;
use App\Models\Expense;
use App\Models\Partner;
use App\Models\PartnerWithdrawal;
use App\Models\Plan;
use App\Models\Subscription;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Str;
use Laravel\Sanctum\Sanctum;
use PHPUnit\Framework\Attributes\Test;
use Spatie\Permission\Models\Role;
use Tests\TestCase;

/**
 * PWA-002: un aporte o retiro de socio cuya respuesta se perdió no se registra
 * dos veces al reintentar.
 */
class PartnerTransactionIdempotencyTest extends TestCase
{
    use DatabaseTransactions;

    private Company $company;

    private Partner $partner;

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

        Role::firstOrCreate(['name' => 'admin', 'guard_name' => 'web']);
        $this->company = Company::factory()->create();
        $plan = Plan::factory()->create(['has_pwa_access' => true]);
        Subscription::factory()->active()->create([
            'company_id' => $this->company->id,
            'plan_id' => $plan->id,
            'ends_at' => now()->addYear(),
        ]);
        $admin = User::factory()->create(['company_id' => $this->company->id]);
        $admin->assignRole('admin');
        // No existe PartnerFactory: 'joined_at' es obligatorio.
        $this->partner = Partner::create([
            'company_id' => $this->company->id,
            'name' => 'Socio de Prueba',
            'joined_at' => now(),
        ]);
        Sanctum::actingAs($admin->fresh());
    }

    private function url(): string
    {
        return "/api/pwa/partners/{$this->partner->id}/transaction";
    }

    /** @return array<string, mixed> */
    private function aporte(array $extra = []): array
    {
        return array_merge([
            'transaction_type' => 'contribution',
            'contribution_type' => 'additional',
            'amount' => 100000,
        ], $extra);
    }

    /** @return array<string, mixed> */
    private function retiro(array $extra = []): array
    {
        return array_merge([
            'transaction_type' => 'withdrawal',
            'withdrawal_type' => 'profit_withdrawal',
            'amount' => 100000,
        ], $extra);
    }

    private function gastosDeLaEmpresa(): int
    {
        return Expense::withoutGlobalScopes()->where('company_id', $this->company->id)->count();
    }

    #[Test]
    public function el_mismo_aporte_dos_veces_queda_una_vez(): void
    {
        $clave = (string) Str::uuid();

        $id = $this->postJson($this->url(), $this->aporte(['idempotency_key' => $clave]))
            ->assertCreated()
            ->json('transaction.id');

        $this->postJson($this->url(), $this->aporte(['idempotency_key' => $clave]))
            ->assertOk()
            ->assertJsonPath('duplicate', true)
            ->assertJsonPath('transaction.id', $id)
            ->assertJsonPath('transaction.type', 'contribution');

        $this->assertSame(1, CapitalContribution::withoutGlobalScopes()->where('idempotency_key', $clave)->count());
    }

    #[Test]
    public function el_reintento_de_un_retiro_que_vacio_el_saldo_devuelve_el_original_y_un_solo_gasto(): void
    {
        $this->postJson($this->url(), $this->aporte())->assertCreated();
        $clave = (string) Str::uuid();
        $gastosAntes = $this->gastosDeLaEmpresa();

        $id = $this->postJson($this->url(), $this->retiro(['idempotency_key' => $clave]))
            ->assertCreated()
            ->json('transaction.id');

        // El primer intento dejó el saldo en cero: sin la deduplicación antes del
        // chequeo de saldo, el reintento se rechazaría con "supera el saldo".
        $this->postJson($this->url(), $this->retiro(['idempotency_key' => $clave]))
            ->assertOk()
            ->assertJsonPath('duplicate', true)
            ->assertJsonPath('transaction.id', $id)
            ->assertJsonPath('transaction.type', 'withdrawal');

        $this->assertSame(1, PartnerWithdrawal::withoutGlobalScopes()->where('idempotency_key', $clave)->count());
        $this->assertSame($gastosAntes + 1, $this->gastosDeLaEmpresa());
    }

    #[Test]
    public function sin_clave_cada_envio_registra_un_movimiento_como_hasta_ahora(): void
    {
        $this->postJson($this->url(), $this->aporte())->assertCreated();
        $this->postJson($this->url(), $this->aporte())->assertCreated();

        $this->assertSame(2, CapitalContribution::withoutGlobalScopes()->where('partner_id', $this->partner->id)->count());
    }

    #[Test]
    public function una_clave_que_no_es_uuid_se_rechaza(): void
    {
        $this->postJson($this->url(), $this->aporte(['idempotency_key' => 'no-es-uuid']))
            ->assertStatus(422)
            ->assertJsonValidationErrors(['idempotency_key']);
    }
}
```

- [ ] **Step 2: Correr los tests y ver que fallan**

Run: `php artisan test tests/Feature/Pwa/PartnerTransactionIdempotencyTest.php`
Expected: FAIL los dos de "dos veces" (el segundo da 201 o 422 "supera el saldo") y el de la clave inválida (201). `sin_clave…` pasa.

- [ ] **Step 3: Implementar**

Sumar el import:

```php
use Illuminate\Database\UniqueConstraintViolationException;
```

En `storeTransaction()`, sumar la regla a `$request->validate([...])`, después de `'simplified_amount_confirmed' => ['nullable', 'boolean'],`:

```php
            'simplified_amount_confirmed' => ['nullable', 'boolean'],
            'idempotency_key' => ['nullable', 'uuid'],
        ]);
```

Reemplazar desde `$partner = Partner::where('company_id', $companyId)->findOrFail($id);` hasta el `return response()->json([...], 201);` final de `storeTransaction()` por:

```php
        $partner = Partner::where('company_id', $companyId)->findOrFail($id);
        $clave = $request->input('idempotency_key');

        // PWA-002: reintento de un movimiento que ya quedó (se perdió la
        // respuesta). Se devuelve el original ANTES del chequeo de saldo: el
        // primer intento ya lo descontó y el reintento se rechazaría por eso.
        if (is_string($clave) && ($existente = $this->movimientoConClave($companyId, $clave))) {
            return $this->movimientoDuplicado($existente, $partner);
        }

        // Validar saldo disponible antes de iniciar la transacción DB.
        // Un retiro que supere el balance actual dejaría al socio en negativo.
        if ($request->transaction_type === 'withdrawal') {
            $currentBalance = (float) $partner->getCurrentBalance()->value();
            $requestedAmount = (float) $request->amount;

            if ($requestedAmount > $currentBalance + 0.01) {
                return response()->json([
                    'message' => 'El monto del retiro supera el saldo disponible del socio.',
                    'errors' => [
                        'amount' => [
                            "El saldo disponible es \${$currentBalance}. No se puede retirar \${$requestedAmount}.",
                        ],
                    ],
                    'current_balance' => round($currentBalance, 2),
                ], 422);
            }
        }

        $opDate = $request->filled('date')
            ? Carbon::parse($request->date)->toDateString()
            : Carbon::today()->toDateString();

        try {
            $result = DB::transaction(function () use ($request, $partner, $user, $companyId, $opDate, $clave) {
                if ($request->transaction_type === 'contribution') {
                    return $this->createContribution($request, $partner, $user, $companyId, $opDate, $clave);
                }

                return $this->createWithdrawal($request, $partner, $user, $companyId, $opDate, $clave);
            });
        } catch (UniqueConstraintViolationException $e) {
            // Carrera: otro envío con la misma clave se registró entre la búsqueda
            // y este insert; el índice único lo frenó y la transacción (con el
            // gasto del retiro) volvió atrás. El movimiento es el del otro envío.
            $existente = is_string($clave) ? $this->movimientoConClave($companyId, $clave) : null;
            if (! $existente) {
                throw $e;
            }

            return $this->movimientoDuplicado($existente, $partner);
        }

        // Refrescar partner para devolver balances actualizados
        $partner->refresh();

        return response()->json([
            'message' => $result['message'],
            'transaction' => $result['transaction'],
            'partner' => $this->formatPartner($partner),
        ], 201);
    }

    /** La clave nombra la operación, sea aporte o retiro. */
    private function movimientoConClave(int $companyId, string $clave): CapitalContribution|PartnerWithdrawal|null
    {
        return CapitalContribution::query()
            ->where('company_id', $companyId)
            ->where('idempotency_key', $clave)
            ->first()
            ?? PartnerWithdrawal::query()
                ->where('company_id', $companyId)
                ->where('idempotency_key', $clave)
                ->first();
    }

    private function movimientoDuplicado(CapitalContribution|PartnerWithdrawal $movimiento, Partner $partner): JsonResponse
    {
        $partner->refresh();

        return response()->json([
            'message' => 'Este movimiento ya estaba registrado.',
            'duplicate' => true,
            'transaction' => $movimiento instanceof CapitalContribution
                ? $this->formatoAporte($movimiento)
                : $this->formatoRetiro($movimiento),
            'partner' => $this->formatPartner($partner),
        ]);
    }

    /** @return array<string, mixed> */
    private function formatoAporte(CapitalContribution $contribution): array
    {
        return [
            'id' => $contribution->id,
            'type' => 'contribution',
            'label' => $contribution->type->getLabel(),
            'amount' => (float) $contribution->amount,
            'date' => $contribution->operation_date->toDateString(),
            'notes' => $contribution->notes,
        ];
    }

    /** @return array<string, mixed> */
    private function formatoRetiro(PartnerWithdrawal $withdrawal): array
    {
        return [
            'id' => $withdrawal->id,
            'type' => 'withdrawal',
            'label' => $withdrawal->type->getLabel(),
            'amount' => (float) $withdrawal->amount,
            'date' => $withdrawal->operation_date->toDateString(),
            'notes' => $withdrawal->notes,
            'expense_id' => $withdrawal->expense_id,
        ];
    }
```

`$companyId` viene de `$user->company_id`: para pasarlo a `movimientoConClave(int …)` con `strict_types`, cambiar su asignación (línea 102) a `$companyId = (int) $user->company_id;`.

Cambiar `createContribution()` para recibir y guardar la clave, y usar el formato compartido:

```php
    private function createContribution(
        Request $request, Partner $partner, $user, int $companyId, string $opDate, ?string $clave
    ): array {
        $contribType = ContributionType::from($request->contribution_type ?? 'additional');

        $contribution = CapitalContribution::create([
            'company_id' => $companyId,
            'partner_id' => $partner->id,
            'amount' => (float) $request->amount,
            'type' => $contribType->value,
            'method' => $request->method,
            'operation_date' => $opDate,
            'notes' => $request->notes,
            'user_id' => $user->id,
            'idempotency_key' => $clave,
        ]);

        return [
            'message' => 'Aporte registrado correctamente.',
            'transaction' => $this->formatoAporte($contribution),
        ];
    }
```

Y `createWithdrawal()` igual: parámetro `?string $clave` al final, `'idempotency_key' => $clave,` en `PartnerWithdrawal::create([...])` (después de `'approved_at' => now(),`), y el `return` pasa a:

```php
        return [
            'message' => 'Retiro registrado correctamente.',
            'transaction' => $this->formatoRetiro($withdrawal),
        ];
```

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

Run: `php artisan test tests/Feature/Pwa/PartnerTransactionIdempotencyTest.php tests/Feature/Pwa/SimplifiedInputExpandTest.php tests/Feature/Pwa/PaymentRequestTenantScopeTest.php`
Expected: PASS.

Run: `php artisan test --filter=Partner`
Expected: PASS.

- [ ] **Step 5: PHPStan, Pint y commit**

Run: `./vendor/bin/phpstan analyse --level=5 app/ --no-progress` → `[OK] No errors`.
Run: `./vendor/bin/pint app/Http/Controllers/Api/Pwa/PartnerController.php tests/Feature/Pwa/PartnerTransactionIdempotencyTest.php`

```bash
git add app/Http/Controllers/Api/Pwa/PartnerController.php tests/Feature/Pwa/PartnerTransactionIdempotencyTest.php
git commit -F /home/jredondo/commit-msg.txt
```

Mensaje: `fix(pwa): un movimiento de socio cuya respuesta se pierde no se registra dos veces (PWA-002)` + por qué el chequeo va antes del saldo + Co-Authored-By.

---

### Task 5: E2E de señal débil (falla con el código de hoy)

**Files:**
- Create: `tests/e2e/senal-debil.spec.js`

Contexto: se escribe antes que la PWA, para verla fallar primero. Mismas reglas que `cola-offline.spec.js`: tras el login **nada puede recargar la página** (el token vive solo en memoria), y los helpers se copian (cada spec lleva los suyos). El service worker está bloqueado en los tests. Los pasos van en el orden en que las tareas siguientes los hacen pasar: los de cobro con la Task 7 (que además suma dos pasos de cobro), la visita con la 8, el gasto con la 9 y el crédito con la 10.

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

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

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

const DB_NAME = 'CredifyGoPWA';

/** Aviso de "Pago Guardado" cuando el cobro quedó en el teléfono (PaymentSuccessView). */
const TEXTO_GUARDADO = 'Guardado en el teléfono. Se sube solo cuando alcance la señal; no hace falta registrarlo otra vez.';
const CREDITO_SIN_RESPUESTA = 'No hubo respuesta del servidor. Puedes reintentar: si ya se había creado, no se duplica.';

// ── Helpers: los mismos de cola-offline.spec.js ─────────────────────────────

/** IndexedDB con la API cruda: Dexie vive dentro del bundle y no está en `window`. */
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);
    });

/** Cambia la marca de envío de la fila de cobro con `clave`. */
const marcar = (db, { clave, hasta }) =>
    new Promise((resolve, reject) => {
        const tx = db.transaction('pendingPayments', 'readwrite');
        const store = tx.objectStore('pendingPayments');
        const req = store.index('idempotency_key').get(clave);
        req.onsuccess = () => {
            if (req.result) store.put({ ...req.result, enviando_hasta: hasta });
        };
        tx.oncomplete = () => resolve(true);
        tx.onerror = () => reject(tx.error);
    });

/** Un crédito activo con cuota pendiente, leído de la caché de la app. */
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')]);
    const elegibles = creditos.filter(
        (c) =>
            ['active', 'delayed', 'overdue'].includes(c.status) &&
            c.remaining_balance > 2000 &&
            cuotas.some((i) => i.credit_id === c.id && ['pending', 'partial_paid'].includes(i.status))
    );
    // Al azar: dos corridas del mismo día sobre el mismo crédito y con el mismo
    // monto las retendría el servidor como posible duplicado.
    return elegibles.length ? elegibles[Math.floor(Math.random() * elegibles.length)] : null;
};

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
    );
}

/** La respuesta del POST a `endpoint` cuyo cuerpo lleva `clave`. */
function respuestaCon(page, endpoint, clave) {
    return page.waitForResponse(
        (r) => r.url().includes(endpoint)
            && r.request().method() === 'POST'
            && (r.request().postData() ?? '').includes(clave),
        { timeout: 30_000 }
    );
}

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

// ── Helpers propios ─────────────────────────────────────────────────────────

/** Una ronda del motor de la cola, como las que lanza la app sola. */
function ronda(page) {
    return page.evaluate(() =>
        document.querySelector('[data-v-app]').__vue_app__.config.globalProperties.$pinia._s.get('sync').syncAll()
    );
}

/**
 * Corta el POST directo a `ruta` (el de la pantalla, no el lote de /sync/…).
 * - 'sin_respuesta': no llega al servidor, como un timeout con señal débil.
 * - 'respuesta_perdida': llega y el servidor lo aplica, pero la respuesta no vuelve.
 */
async function cortarEnvioDirecto(page, ruta, modo) {
    const visto = { cuerpos: [], respuesta: null };
    const esRuta = (url) => url.pathname === ruta;
    const cortar = async (route) => {
        if (route.request().method() !== 'POST') return route.continue();
        visto.cuerpos.push(route.request().postDataJSON());
        if (modo === 'respuesta_perdida') {
            visto.respuesta = await (await route.fetch()).json();
        }
        return route.abort('timedout');
    };
    await page.route(esRuta, cortar);
    return { visto, restablecer: () => page.unroute(esRuta, cortar) };
}

/** Entre $1,01 y $1,99 (x1.000 en modo simplificado), distinto en cada corrida. */
const montoAlAzar = () => (1 + randomInt(1, 100) / 100).toFixed(2);

async function registrarCobro(page, creditoId) {
    await irA(page, `/pwa/credits/${creditoId}/pay`);
    await expect(page.getByRole('button', { name: 'Confirmar Pago' })).toBeVisible({ timeout: 15_000 });
    await page.locator('input[type="number"]').first().fill(montoAlAzar());
    await page.getByRole('button', { name: 'Confirmar Pago' }).click();
}

test.describe('señal débil: una respuesta perdida no pierde ni duplica nada (PWA-002)', () => {
    // 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('cobro, visita, gasto y crédito sobreviven a una respuesta que no vuelve', async ({ page }) => {
        test.setTimeout(240_000);

        await loginAsCollector(page);
        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 sin respuesta queda guardado, avisa y sube una sola vez', async () => {
            const { visto, restablecer } = await cortarEnvioDirecto(page, '/api/pwa/payments', 'sin_respuesta');
            await registrarCobro(page, credito.id);

            // Antes de PWA-002: "No se pudo conectar con el servidor" y el cobro no
            // quedaba en ninguna parte. `exact`: el toast "Pago guardado (pendiente…)"
            // también contiene el texto.
            await expect(page.getByText('Pago Guardado', { exact: true })).toBeVisible({ timeout: 20_000 });
            await expect(page.getByText(TEXTO_GUARDADO)).toBeVisible();
            await restablecer();

            const [enCola] = await idb(page, readAll, 'pendingPayments');
            expect(enCola, 'el cobro debe quedar en la cola').toBeTruthy();
            // El intento directo y la fila llevan la MISMA clave.
            expect(visto.cuerpos[0].idempotency_key).toBe(enCola.idempotency_key);
            expect(enCola.captured_by_user_id).toBe(yo);
            expect(enCola.enviando_hasta ?? null, 'al fallar se quita la marca de envío').toBeNull();

            // Volver a cobrar ese crédito avisa que ya hay un cobro esperando señal.
            await irA(page, `/pwa/credits/${credito.id}/pay`);
            await expect(page.getByText('guardado para este crédito, esperando señal')).toBeVisible({ timeout: 15_000 });

            const subida = respuestaCon(page, '/api/pwa/sync/payments', enCola.idempotency_key);
            await ronda(page);
            const [resultado] = (await (await subida).json()).results;
            expect(resultado.idempotency_key).toBe(enCola.idempotency_key);
            // Nuevo para el servidor (no 'duplicate'): el intento directo no había llegado.
            expect(['success', 'held']).toContain(resultado.status);
            await esperarColaVacia(page, 'pendingPayments');
        });

        await test.step('un cobro que llega pero pierde la respuesta no se cobra dos veces', async () => {
            const { visto, restablecer } = await cortarEnvioDirecto(page, '/api/pwa/payments', 'respuesta_perdida');
            await registrarCobro(page, credito.id);

            await expect(page.getByText('Pago Guardado', { exact: true })).toBeVisible({ timeout: 20_000 });
            await restablecer();

            const aplicado = visto.respuesta?.payment;
            expect(aplicado?.id, 'el servidor debió aplicar el cobro').toBeTruthy();
            const [enCola] = await idb(page, readAll, 'pendingPayments');
            expect(enCola.idempotency_key).toBe(visto.cuerpos[0].idempotency_key);

            const subida = respuestaCon(page, '/api/pwa/sync/payments', enCola.idempotency_key);
            await ronda(page);
            expect((await (await subida).json()).results[0]).toMatchObject({
                idempotency_key: enCola.idempotency_key,
                status: 'duplicate',
                payment_id: aplicado.id,
            });
            await esperarColaVacia(page, 'pendingPayments');
        });

        await test.step('el motor no manda lo que una pantalla está enviando, y sí lo que quedó a medias', async () => {
            const fila = (clave, hasta) => ({
                idempotency_key: clave,
                credit_id: credito.id,
                amount: 1 + randomInt(1, 100) / 100,
                payment_method: 'cash',
                payment_date: hoy,
                created_at_local: new Date().toISOString(),
                captured_by_user_id: yo,
                enviando_hasta: hasta,
            });
            const enEnvio = randomUUID();
            const aMedias = randomUUID();
            await idb(page, putRows, {
                store: 'pendingPayments',
                rows: [
                    fila(enEnvio, Date.now() + 10 * 60_000), // una pantalla lo está enviando ahora
                    fila(aMedias, Date.now() - 1_000), // la app murió a mitad de envío: la marca venció
                ],
            });

            const primera = respuestaCon(page, '/api/pwa/sync/payments', aMedias);
            await ronda(page);
            const respuesta = await primera;
            expect(respuesta.request().postData(), 'lo que se está enviando no va en el lote').not.toContain(enEnvio);
            const resultados = (await respuesta.json()).results;
            expect(resultados).toHaveLength(1);
            expect(['success', 'held']).toContain(resultados[0].status);

            // Vencida su marca, la siguiente ronda también lo sube.
            await idb(page, marcar, { clave: enEnvio, hasta: Date.now() - 1_000 });
            const segunda = respuestaCon(page, '/api/pwa/sync/payments', enEnvio);
            await ronda(page);
            expect(['success', 'held']).toContain((await (await segunda).json()).results[0].status);
            await esperarColaVacia(page, 'pendingPayments');
        });

        await test.step('una visita "No paga" que pierde la respuesta no se registra dos veces', async () => {
            const { visto, restablecer } = await cortarEnvioDirecto(page, '/api/pwa/visits', 'respuesta_perdida');
            await irA(page, `/pwa/credits/${credito.id}`);
            await page.getByRole('button', { name: 'No paga' }).click();
            await page.getByRole('button', { name: 'Sin dinero' }).click();
            await page.getByRole('button', { name: 'Registrar Visita' }).click();

            await expect(page.getByText('Visita guardada (pendiente de sincronizar)')).toBeVisible({ timeout: 20_000 });
            await restablecer();

            const aplicada = visto.respuesta?.visit;
            expect(aplicada?.id, 'el servidor debió registrar la visita').toBeTruthy();
            const [enCola] = await idb(page, readAll, 'pendingVisits');
            expect(enCola.idempotency_key).toBe(visto.cuerpos[0].idempotency_key);

            const subida = respuestaCon(page, '/api/pwa/sync/visits', enCola.idempotency_key);
            await ronda(page);
            expect((await (await subida).json()).results[0]).toMatchObject({
                idempotency_key: enCola.idempotency_key,
                status: 'duplicate',
                visit_id: aplicada.id,
            });
            await esperarColaVacia(page, 'pendingVisits');
        });

        await test.step('un gasto que pierde la respuesta no se registra dos veces', async () => {
            const { visto, restablecer } = await cortarEnvioDirecto(page, '/api/pwa/expenses', 'respuesta_perdida');
            await irA(page, '/pwa/expenses/new');
            await expect(page.getByRole('button', { name: 'Registrar Gasto' })).toBeVisible({ timeout: 15_000 });
            await page.locator('input[type="number"]').first().fill(montoAlAzar());
            await page.getByRole('button', { name: 'Transporte' }).click();
            await page.getByRole('button', { name: 'Registrar Gasto' }).click();

            await expect(page.getByText('Gasto guardado — se sube solo cuando alcance la señal')).toBeVisible({ timeout: 20_000 });
            await restablecer();

            const aplicado = visto.respuesta?.expense;
            expect(aplicado?.id, 'el servidor debió registrar el gasto').toBeTruthy();
            const [enCola] = await idb(page, readAll, 'pendingExpenses');
            expect(enCola.idempotency_key).toBe(visto.cuerpos[0].idempotency_key);

            const subida = respuestaCon(page, '/api/pwa/sync/expenses', enCola.idempotency_key);
            await ronda(page);
            // El lote de gastos responde "ok" tanto para creado como para duplicado:
            // lo que prueba que no hubo otro gasto es el id.
            expect((await (await subida).json()).results[0]).toMatchObject({
                idempotency_key: enCola.idempotency_key,
                status: 'ok',
                expense_id: aplicado.id,
            });
            await esperarColaVacia(page, 'pendingExpenses');
        });

        await test.step('un crédito cuya respuesta se pierde no se crea dos veces al reintentar', async () => {
            const cuerpos = [];
            let creado = null;
            const esCreditos = (url) => url.pathname === '/api/pwa/credits';
            const cortar = async (route) => {
                if (route.request().method() !== 'POST') return route.continue();
                cuerpos.push(route.request().postDataJSON());
                if (cuerpos.length === 1) {
                    // El primero llega y el servidor crea el crédito, pero la respuesta se pierde.
                    creado = (await (await route.fetch()).json()).credit;
                    return route.abort('timedout');
                }
                return route.continue();
            };
            await page.route(esCreditos, cortar);

            await irA(page, `/pwa/credits/new?client_id=${credito.client_id}`);
            await expect(page.getByRole('button', { name: 'Crear Crédito' })).toBeVisible({ timeout: 15_000 });

            // Valores que la empresa acepta, en las unidades que muestra el formulario.
            const valores = await page.evaluate(() => {
                const s = document.querySelector('[data-v-app]').__vue_app__.config.globalProperties.$pinia._s.get('settings');
                const c = s.creditConfig;
                return {
                    monto: s.convertToSimplified(Number(c.min_amount) || 1000, 'credit'),
                    tasa: Math.min(Math.max(10, Number(c.min_interest_rate) || 0), Number(c.max_interest_rate) || 100),
                    cuotas: Math.min(Math.max(4, Number(c.min_installments) || 1), Number(c.max_installments) || 365),
                };
            });
            await page.getByPlaceholder('100000', { exact: true }).fill(String(valores.monto));
            await page.getByPlaceholder('10', { exact: true }).fill(String(valores.tasa));
            await page.getByPlaceholder('12', { exact: true }).fill(String(valores.cuotas));
            await page.getByRole('button', { name: 'Crear Crédito' }).click();

            await expect(page.getByText(CREDITO_SIN_RESPUESTA).first()).toBeVisible({ timeout: 20_000 });
            expect(creado?.id, 'el servidor debió crear el crédito').toBeTruthy();

            // El reintento sale con la misma clave y lleva al crédito que ya existía.
            await page.getByRole('button', { name: 'Crear Crédito' }).click();
            await expect(page).toHaveURL(new RegExp(`/pwa/credits/${creado.id}$`), { timeout: 20_000 });
            expect(cuerpos).toHaveLength(2);
            expect(cuerpos[1].idempotency_key).toBe(cuerpos[0].idempotency_key);
            await page.unroute(esCreditos, cortar);
        });
    });
});
```

- [ ] **Step 2: Correr el spec y verlo fallar donde debe**

Run: `bash /home/jredondo/e2e-senal.sh tests/e2e/senal-debil.spec.js`
Expected: FAIL en el paso 1, en `expect(page.getByText('Pago Guardado', { exact: true })).toBeVisible()` (la pantalla muestra "No se pudo conectar con el servidor" y no navega). Si falla antes (login, crédito elegible), arreglar el spec hasta que el fallo sea ese.

- [ ] **Step 3: Commit**

```bash
git add tests/e2e/senal-debil.spec.js
git commit -F /home/jredondo/commit-msg.txt
```

Mensaje: `test(e2e): señal débil — respuesta perdida en cobro, visita, gasto y crédito (PWA-002)` + "falla con el código de hoy en el paso 1" + Co-Authored-By.

---

### Task 6: `enviarOEncolar` y la marca de envío en el motor

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

Contexto: `queuePayment`, `queueVisit` y `queueExpense` **se quedan** en esta tarea (las pantallas todavía los llaman); los quita la Task 9, cuando ya no tienen llamadores. Así `cola-offline.spec.js` sigue en verde en cada paso.

- [ ] **Step 1: Crear `resources/js/pwa/utils/transporte.js`**

```js
/**
 * Sin respuesta, timeout, 5xx, 408, 429 o sesión caída (401/419): el servidor no
 * juzgó el envío, así que se puede reintentar con la misma clave de idempotencia.
 *
 * La usan el motor de la cola y los formularios que no van a la cola (crédito,
 * movimiento de socio): los dos deciden igual qué es "no hubo respuesta".
 */
export function esFalloDeTransporte(err) {
    const status = err?.response?.status
    return !status || status >= 500 || [401, 408, 419, 429].includes(status)
}
```

- [ ] **Step 2: Usarla en `sync.js` y sumar las constantes**

Imports (sumar la línea de `transporte`):

```js
import { generateUUID } from '../utils/uuid'
import { esFalloDeTransporte } from '../utils/transporte'
import { useAuthStore } from './auth'
```

Después de `const MAX_SYNC_RETRIES = 3`:

```js
const MAX_SYNC_RETRIES = 3

// PWA-002: el POST directo de cobro, visita y gasto espera como mucho esto. Un
// cobro tarda menos de un segundo en el servidor; si no volvió nada, esperar más
// no ayuda al cobrador y reintentar es seguro por la clave.
const ENVIO_DIRECTO_MS = 10_000

// Mientras una pantalla envía su fila, el motor no la manda en el lote. Es el
// timeout más un margen: si la app muere a mitad de envío, la marca vence sola.
const MARCA_ENVIO_MS = 15_000
```

Borrar la definición local de `esFalloDeTransporte` (el comentario `/** Sin respuesta, timeout, 5xx, 429 o sesión caída… */` y la función, líneas 100-104) y en su lugar poner:

```js
/** Una pantalla la está enviando ahora mismo (PWA-002): el lote no la manda. */
function enEnvio(fila) {
    return typeof fila.enviando_hasta === 'number' && fila.enviando_hasta > Date.now()
}
```

- [ ] **Step 3: El envío directo de cada tipo en `COLAS`**

En cada entrada de `COLAS`, después de `lote: …,`:

```js
    payments: {
        tabla: 'pendingPayments',
        endpoint: '/pwa/sync/payments',
        campo: 'payments',
        lote: 50,
        // El POST de la pantalla (PWA-002) y lo que confirma que el servidor lo tiene.
        directo: { endpoint: '/pwa/payments', confirma: (data) => Boolean(data?.payment) },
```

```js
    visits: {
        tabla: 'pendingVisits',
        endpoint: '/pwa/sync/visits',
        campo: 'visits',
        lote: 100,
        directo: { endpoint: '/pwa/visits', confirma: (data) => Boolean(data?.visit) },
```

```js
    expenses: {
        tabla: 'pendingExpenses',
        endpoint: '/pwa/sync/expenses',
        campo: 'expenses',
        lote: 100,
        directo: { endpoint: '/pwa/expenses', confirma: (data) => Boolean(data?.expense) },
```

- [ ] **Step 4: El motor salta las filas en envío**

En `sincronizarCola`, reemplazar la lectura de pendientes:

```js
            // filter(), NUNCA where() sobre un booleano: ver db/index.js, versión 8.
            // Tampoco lo que una pantalla está enviando ahora (PWA-002).
            pendientes = await tabla.filter((x) => !x.permanent_error && !enEnvio(x)).toArray()
```

- [ ] **Step 5: `enviarOEncolar`**

Justo después de `queueExpense` (antes del bloque `// ── Motor ──`):

```js
    // ── Envío directo con la cola como red (PWA-002) ────────────────────────
    //
    // Cobro, visita "No paga" y gasto se guardan en la cola ANTES de salir y solo
    // se sueltan cuando el servidor responde. Con señal débil el teléfono se cree
    // conectado, el POST no vuelve, y antes el registro no quedaba en ninguna parte.

    /** Saldo optimista para que las listas reflejen el cobro ya. Si falla, el cobro sigue en cola. */
    async function descontarSaldoOptimista(tipo, fila) {
        if (tipo !== 'payments') return
        try {
            await db.deductCreditBalance(fila.credit_id, fila.amount)
        } catch (err) {
            console.warn('[Sync] Optimistic balance deduction failed (payment still queued):', err)
        }
    }

    /** El envío directo no se confirmó: la fila queda para el motor, ya sin marca. */
    async function dejarParaElMotor(tipo, fila) {
        try {
            await db[COLAS[tipo].tabla].update(fila.localId, { enviando_hasta: null })
        } catch (err) {
            // La marca vence sola: el motor la subirá igual en unos segundos.
            reportarFallo(err, { cola: tipo, paso: 'quitar_marca' })
        }
        await descontarSaldoOptimista(tipo, fila)
        await cargar(tipo)
        return { estado: 'en_cola', fila }
    }

    /** Suelta la fila que el servidor ya juzgó, sin tapar el resultado si IndexedDB falla. */
    async function soltarJuzgada(tipo, fila) {
        try {
            await soltar(db[COLAS[tipo].tabla], fila)
            await cargar(tipo)
        } catch (err) {
            // Si queda, el motor la manda y el servidor responde duplicate (o la rechaza otra vez).
            reportarFallo(err, { cola: tipo, paso: 'soltar' })
        }
    }

    /**
     * @param {'payments'|'visits'|'expenses'} tipo
     * @param {object} datos  lo que la pantalla envía, sin clave
     * @returns {Promise<{estado: 'enviado', respuesta: object} | {estado: 'en_cola', fila: object}>}
     *          Un rechazo real del servidor (4xx salvo 401/408/419/429) se relanza:
     *          no lo registró y la pantalla lo muestra para corregirlo en el momento.
     */
    async function enviarOEncolar(tipo, datos) {
        const cfg = COLAS[tipo]
        const conSenal = navigator.onLine
        const fila = {
            ...datos,
            idempotency_key: generateUUID(),
            created_at_local: new Date().toISOString(),
            captured_by_user_id: capturador(),
        }

        // IndexedDB primero: desde acá el registro existe en el teléfono pase lo
        // que pase. Con señal, la marca aparta la fila del motor mientras esta
        // pantalla la envía.
        let guardada = true
        try {
            fila.localId = await db[cfg.tabla].add(
                conSenal ? { ...fila, enviando_hasta: Date.now() + MARCA_ENVIO_MS } : fila
            )
        } catch (err) {
            // Sin IndexedDB (lleno o bloqueado): con señal se envía directo como
            // antes de PWA-002; sin señal no hay dónde guardarlo.
            reportarFallo(err, { cola: tipo, paso: 'guardar' })
            if (!conSenal) throw err
            guardada = false
        }

        if (!conSenal) {
            await descontarSaldoOptimista(tipo, fila)
            await cargar(tipo)
            return { estado: 'en_cola', fila }
        }
        if (guardada) await cargar(tipo)

        let respuesta
        try {
            respuesta = await api.post(
                cfg.directo.endpoint,
                { ...datos, idempotency_key: fila.idempotency_key },
                { timeout: ENVIO_DIRECTO_MS }
            )
        } catch (err) {
            if (!esFalloDeTransporte(err)) {
                // El servidor lo juzgó y no lo registró (monto, permisos, crédito).
                if (guardada) await soltarJuzgada(tipo, fila)
                throw err
            }
            reportarFallo(err, { cola: tipo, paso: 'envio_directo' })
            if (!guardada) throw err
            return dejarParaElMotor(tipo, fila)
        }

        if (!cfg.directo.confirma(respuesta?.data)) {
            // Un 2xx que no trae el registro (portal cautivo, proxy): no dice si el
            // servidor lo tiene. Queda en la cola; si ya lo tenía, el lote dirá duplicate.
            Sentry.captureMessage(`Envío directo sin confirmación en la cola ${tipo}`, {
                level: 'warning',
                tags: { cola: tipo, http_status: respuesta?.status ?? 'sin_respuesta' },
            })
            if (!guardada) throw new Error('El servidor respondió algo inesperado. Intenta de nuevo.')
            return dejarParaElMotor(tipo, fila)
        }

        if (guardada) await soltarJuzgada(tipo, fila)
        return { estado: 'enviado', respuesta }
    }
```

En el `return` del store, sumar `enviarOEncolar` en `// Actions`, después de `queueExpense,`:

```js
        queueExpense,
        enviarOEncolar,
        syncAll,
```

- [ ] **Step 6: Build y E2E de regresión**

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

Run: `bash /home/jredondo/e2e-senal.sh tests/e2e/cola-offline.spec.js tests/e2e/sync-purge.spec.js`
Expected: PASS (el motor sigue subiendo las filas sin marca, que son todas las de hoy).

Run: `bash /home/jredondo/e2e-senal.sh tests/e2e/senal-debil.spec.js`
Expected: sigue FAIL en el paso 1 (ninguna pantalla usa todavía `enviarOEncolar`).

- [ ] **Step 7: Commit**

```bash
git add resources/js/pwa/utils/transporte.js resources/js/pwa/stores/sync.js
git commit -F /home/jredondo/commit-msg.txt
```

Mensaje: `feat(pwa): enviarOEncolar — la cola como red del envío directo (PWA-002)` + resumen de la clasificación y de la marca + Co-Authored-By.

---

### Task 7: Registrar pago

**Files:**
- Modify: `resources/js/pwa/views/PaymentView.vue`
- Modify: `resources/js/pwa/views/PaymentSuccessView.vue` (aviso, línea 35)

- [ ] **Step 1: Texto del aviso en `PaymentSuccessView.vue`**

Reemplazar:

```html
            <p class="text-amber-200 dark:text-amber-300 text-sm text-center">
                Se sincronizará cuando tengas conexión
            </p>
```

por:

```html
            <p class="text-amber-200 dark:text-amber-300 text-sm text-center">
                Guardado en el teléfono. Se sube solo cuando alcance la señal; no hace falta registrarlo otra vez.
            </p>
```

- [ ] **Step 2: Aviso de cobros en cola en el template de `PaymentView.vue`**

Después del bloque `<!-- Payment Summary -->` (el `<div v-if="credit.payment_info" …>…</div>`) y antes de `<!-- Payment form -->`:

```html
            <!-- Cobros de este crédito que siguen en el teléfono (PWA-002) -->
            <div v-if="avisoEnCola" class="px-4 mt-3">
                <div class="flex items-start gap-3 rounded-2xl px-4 py-3 pwa-notice-amber" role="status">
                    <div class="w-2 h-2 mt-1.5 rounded-full bg-amber-400 shrink-0"></div>
                    <p class="text-amber-700 dark:text-amber-300 text-sm">{{ avisoEnCola }}</p>
                </div>
            </div>
```

- [ ] **Step 3: Script — imports, GPS y aviso**

Borrar el import que queda sin uso:

```js
import { generateUUID } from '../utils/uuid'
```

Después de `const canSubmit = computed(…)` (termina en la línea 190), sumar:

```js
// Cobros de este crédito que siguen en el teléfono (PWA-002). El saldo del
// servidor todavía no los incluye: sin el aviso, el cobrador creería que no
// quedaron y los volvería a cargar.
const cobrosEnCola = computed(() =>
    syncStore.ownPendingPayments.filter(
        (p) => p.credit_id === creditId.value && !(p.enviando_hasta > Date.now())
    )
)

const avisoEnCola = computed(() => {
    const lista = cobrosEnCola.value
    if (submitting.value || lista.length === 0) return null
    if (lista.length === 1) {
        const [cobro] = lista
        const hora = cobro.created_at_local
            ? new Date(cobro.created_at_local).toLocaleTimeString('es-CO', { hour: '2-digit', minute: '2-digit' })
            : null
        return `Ya hay un cobro de $${formatNumber(cobro.amount)}${hora ? ` de las ${hora}` : ''} guardado para este crédito, esperando señal.`
    }
    const total = lista.reduce((suma, p) => suma + (Number(p.amount) || 0), 0)
    return `Ya hay ${lista.length} cobros guardados para este crédito ($${formatNumber(total)} en total), esperando señal.`
})
```

Reemplazar `const paymentIdempotencyKey = ref(generateUUID())` por:

```js
// GPS pedido al abrir la pantalla (PWA-002): al confirmar casi siempre ya está,
// en vez de esperar hasta 5 s con el cliente enfrente.
let gpsListo = null

function pedirGps() {
    return getCurrentPosition(navigator.onLine ? {} : { maximumAge: 5 * 60 * 1000 })
}
```

- [ ] **Step 4: Script — `submitPayment`**

En `submitPayment`, reemplazar:

```js
    const gps = await getCurrentPosition(
        navigator.onLine ? {} : { maximumAge: 5 * 60 * 1000 }
    )
```

por:

```js
    const gps = await (gpsListo ?? pedirGps())
```

Reemplazar desde `try {` hasta el cierre del `else { … }` del camino sin señal (líneas 285-359 originales, justo antes de `} catch (err) {`) por:

```js
    try {
        // Con o sin señal, el cobro queda en la cola antes de salir y solo se
        // suelta cuando el servidor responde (PWA-002).
        const resultado = await syncStore.enviarOEncolar('payments', paymentData)

        if (resultado.estado === 'en_cola') {
            toast.success('Pago guardado (pendiente de sincronizar)')
            router.push({
                name: 'payment-success',
                query: {
                    amount: realAmount.value,
                    client: credit.value?.client?.name ?? '',
                    offline: true
                }
            })
            return
        }

        const response = resultado.respuesta

        const METHOD_LABELS = { cash: 'Efectivo', transfer: 'Transferencia', mobile: 'Pago móvil' }
        if (response.data.payment?.id) {
            await db.payments.put({
                id:                   response.data.payment.id,
                credit_id:            paymentData.credit_id,
                client_name:          response.data.payment.client_name ?? credit.value?.client?.name ?? '',
                amount:               response.data.payment.amount ?? realAmount.value,
                payment_method:       paymentData.payment_method,
                payment_method_label: METHOD_LABELS[paymentData.payment_method] ?? paymentData.payment_method,
                payment_date:         paymentData.payment_date,
                registered_by:        '',
                registered_at:        response.data.payment.registered_at ?? new Date().toISOString(),
                idempotency_key:      response.data.payment.idempotency_key ?? null,
            })
        }

        if (response.data.credit_status) {
            const localUpdates = {
                status: response.data.credit_status,
                remaining_balance: response.data.credit_new_balance ?? 0,
            }
            await db.credits.update(paymentData.credit_id, localUpdates)

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

        toast.success('Pago registrado')
        router.push({
            name: 'payment-success',
            query: {
                amount: realAmount.value,
                client: credit.value?.client?.name ?? '',
                payment_id: response.data.payment?.id,
                new_balance: response.data.credit_new_balance,
                credit_status: response.data.credit_status,
                installments_remaining: response.data.installments_remaining,
                next_payment_date: response.data.next_payment?.date,
                next_payment_amount: response.data.next_payment?.amount
            }
        })
```

El `catch (err) { … }` y el `finally` quedan como están.

- [ ] **Step 5: Script — `onMounted`**

```js
onMounted(() => {
    gpsListo = pedirGps()
    // El aviso de cobros en cola lee la cola: releerla al abrir la pantalla.
    syncStore.loadPendingPayments()
    fetchCredit()
})
```

- [ ] **Step 6: Dos pasos más en el E2E: las ramas que sueltan o guardan sin fallo de red**

Agregado tras la revisión de la Task 6: el E2E solo usaba `route.abort`, y una regresión en `esFalloDeTransporte` o en `confirma` perdería o duplicaría registros sin que nada fallara. En `tests/e2e/senal-debil.spec.js`, inmediatamente después del paso "el motor no manda lo que una pantalla está enviando…" (y antes del de la visita), agregar:

```js
        await test.step('un rechazo real del servidor muestra el error y no deja nada en la cola', async () => {
            const esPagos = (url) => url.pathname === '/api/pwa/payments';
            const rechazar = (route) => route.request().method() !== 'POST'
                ? route.continue()
                : route.fulfill({
                    status: 422,
                    contentType: 'application/json',
                    body: JSON.stringify({
                        message: 'El monto supera el saldo pendiente.',
                        errors: { amount: ['El monto supera el saldo pendiente.'] },
                    }),
                });
            await page.route(esPagos, rechazar);
            await registrarCobro(page, credito.id);

            // El servidor lo juzgó y no lo registró: se muestra para corregirlo ya.
            await expect(page.getByText('El monto supera el saldo pendiente.').first()).toBeVisible({ timeout: 15_000 });
            await page.unroute(esPagos, rechazar);
            await expect(page.getByText('Pago Guardado', { exact: true })).toHaveCount(0);
            // Y la fila se soltó: no quedó nada para el motor.
            await esperarColaVacia(page, 'pendingPayments');
        });

        await test.step('un 200 que no trae el cobro (portal cautivo) lo deja guardado para el motor', async () => {
            const esPagos = (url) => url.pathname === '/api/pwa/payments';
            let cuerpo = null;
            const portal = (route) => {
                if (route.request().method() !== 'POST') return route.continue();
                cuerpo = route.request().postDataJSON();
                return route.fulfill({
                    status: 200,
                    contentType: 'text/html',
                    body: '<html><body>Inicia sesión en la red Wi-Fi</body></html>',
                });
            };
            await page.route(esPagos, portal);
            await registrarCobro(page, credito.id);

            await expect(page.getByText('Pago Guardado', { exact: true })).toBeVisible({ timeout: 20_000 });
            await page.unroute(esPagos, portal);

            const cola = await idb(page, readAll, 'pendingPayments');
            expect(cola, 'un solo registro en la cola').toHaveLength(1);
            const [enCola] = cola;
            expect(enCola.idempotency_key).toMatch(/^[0-9a-f-]{36}$/i);
            expect(enCola.idempotency_key).toBe(cuerpo.idempotency_key);
            expect(enCola.enviando_hasta ?? null, 'al no confirmarse se quita la marca').toBeNull();

            // Nunca llegó al servidor: el motor lo sube como nuevo.
            const subida = respuestaCon(page, '/api/pwa/sync/payments', enCola.idempotency_key);
            await ronda(page);
            expect(['success', 'held']).toContain((await (await subida).json()).results[0].status);
            await esperarColaVacia(page, 'pendingPayments');
        });
```

`registrarCobro` ya pasa por el detalle del crédito antes de abrir la pantalla de pago, así que cada paso arranca con una instancia nueva de `PaymentView`.

- [ ] **Step 7: Build y E2E**

Run: `grep -n "paymentIdempotencyKey\|generateUUID\|queuePayment" resources/js/pwa/views/PaymentView.vue`
Expected: sin resultados.

Run: `npm run build` → OK.

Run: `bash /home/jredondo/e2e-senal.sh tests/e2e/senal-debil.spec.js`
Expected: pasan todos los pasos de cobro (sin respuesta, respuesta perdida, marca de envío, rechazo real y 200 sin cobro); FAIL en el de la visita "No paga" (todavía no usa la cola: sin respuesta muestra un error y no aparece "Visita guardada").

Run: `bash /home/jredondo/e2e-senal.sh tests/e2e/cola-offline.spec.js`
Expected: PASS (el cobro sin señal ahora entra por `enviarOEncolar`).

- [ ] **Step 8: Commit**

```bash
git add resources/js/pwa/views/PaymentView.vue resources/js/pwa/views/PaymentSuccessView.vue tests/e2e/senal-debil.spec.js
git commit -F /home/jredondo/commit-msg.txt
```

Mensaje: `fix(pwa): un cobro sin respuesta queda guardado y avisa si ya hay otro en cola (PWA-002)` + Co-Authored-By.

---

### Task 8: "No paga"

**Files:**
- Modify: `resources/js/pwa/components/collections/NoPaymentModal.vue`

- [ ] **Step 1: Imports**

Borrar los dos imports que quedan sin uso (el POST lo hace ahora el store):

```js
import api from '../../services/api'
```

```js
import { generateUUID } from '../../utils/uuid'
```

Antes de borrarlos, confirmar que no tienen otro uso: `grep -n "api\.\|generateUUID" resources/js/pwa/components/collections/NoPaymentModal.vue` → solo deben aparecer las líneas que esta tarea cambia.

- [ ] **Step 2: La clave deja de vivir en el modal; el GPS se pide al abrir**

Reemplazar `const visitIdempotencyKey = ref(generateUUID())` por:

```js
// GPS pedido al abrir el modal (PWA-002): al registrar casi siempre ya está.
let gpsListo = null
```

Reemplazar el `watch` del final:

```js
// Reset form when modal opens
watch(() => props.modelValue, (val) => {
    if (val) {
        resetForm()
        gpsListo = getCurrentPosition()
    }
})
```

- [ ] **Step 3: `submit`**

Reemplazar:

```js
    // Capturar GPS silenciosamente (máx 5s — no bloquea si falla)
    const gps = await getCurrentPosition()
```

por:

```js
    // GPS pedido al abrir el modal (máx 5 s desde entonces; no bloquea si falla)
    const gps = await (gpsListo ?? getCurrentPosition())
```

Reemplazar el bloque `try { if (navigator.onLine) { … } else { … } }` (hasta antes de `} catch (err) {`) por:

```js
    try {
        // Con o sin señal, la visita queda en la cola antes de salir y solo se
        // suelta cuando el servidor responde (PWA-002).
        const resultado = await syncStore.enviarOEncolar('visits', visitData)

        if (resultado.estado === 'en_cola') {
            toast.success('Visita guardada (pendiente de sincronizar)')
            emit('success', { offline: true })
        } else {
            toast.success('Visita registrada')
            emit('success', resultado.respuesta.data.visit)
        }
        close()
```

El `catch` y el `finally` quedan como están.

- [ ] **Step 4: Build y E2E**

Run: `grep -n "visitIdempotencyKey\|queueVisit" resources/js/pwa/components/collections/NoPaymentModal.vue` → sin resultados.
Run: `npm run build` → OK.
Run: `bash /home/jredondo/e2e-senal.sh tests/e2e/senal-debil.spec.js`
Expected: pasan los pasos de cobro y el de la visita; FAIL en el del gasto.

- [ ] **Step 5: Commit**

```bash
git add resources/js/pwa/components/collections/NoPaymentModal.vue
git commit -F /home/jredondo/commit-msg.txt
```

Mensaje: `fix(pwa): una visita "No paga" sin respuesta queda guardada (PWA-002)` + Co-Authored-By.

---

### Task 9: Registrar gasto, y una sola puerta a la cola

**Files:**
- Modify: `resources/js/pwa/views/ExpenseCreateView.vue`
- Modify: `resources/js/pwa/stores/sync.js` (quitar `queuePayment`, `queueVisit` y `queueExpense`; `pedirRonda` se queda, la usa `enviarOEncolar`)

- [ ] **Step 1: `ExpenseCreateView.vue`**

Borrar el import sin uso (confirmar antes con `grep -n "api\." resources/js/pwa/views/ExpenseCreateView.vue` que solo lo usa el POST que se reemplaza):

```js
import api from '../services/api'
```

Defensa contra el doble toque, como primera línea de `submit()` (agregado tras la revisión de la Task 6: cada llamada a `enviarOEncolar` genera una clave nueva, así que dos toques rápidos serían dos gastos; el botón solo tiene `:disabled` y el cobro y la visita ya se defienden con `canSubmit`):

```js
async function submit() {
    // Cada envío nace con su propia clave: un segundo toque mientras el primero
    // sale sería otro gasto (PWA-002).
    if (submitting.value) return
    submitted.value = true
```

Reemplazar el bloque `try { … } catch (e) { … }` de `submit()` por:

```js
    try {
        // Con o sin señal, el gasto queda en la cola antes de salir y solo se
        // suelta cuando el servidor responde (PWA-002).
        const resultado = await syncStore.enviarOEncolar('expenses', expenseData)

        if (resultado.estado === 'en_cola') {
            showToast('Gasto guardado — se sube solo cuando alcance la señal', 'success')
        } else {
            showToast(
                requiresApproval.value
                    ? 'Gasto registrado — pendiente de aprobación'
                    : 'Gasto registrado y aprobado',
                'success'
            )
        }

        setTimeout(() => router.back(), 1800)
    } catch (e) {
        const msg = e.response?.data?.message || 'Error al registrar el gasto. Intenta de nuevo.'
        showToast(msg, 'error')
        submitting.value = false
    }
```

- [ ] **Step 2: Quitar las puertas viejas de `sync.js`**

Confirmar que no quedan llamadores:

Run: `grep -rn "queuePayment\|queueVisit\|queueExpense" resources/js/pwa tests/e2e`
Expected: solo `resources/js/pwa/stores/sync.js`.

En `sync.js`, borrar completas las funciones `queuePayment`, `queueVisit` y `queueExpense`, y sus tres entradas en el `return`:

```js
        queuePayment,
        queueVisit,
        queueExpense,
```

`pedirRonda()` y `capturador()` se quedan (con sus comentarios): las usa `enviarOEncolar` (la ronda tras un envío directo exitoso y el capturador de cada fila). El encabezado `// ── Encolar ──` queda sobre ellas.

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

Run: `grep -rn "queuePayment\|queueVisit\|queueExpense" resources/js/pwa` → sin resultados.
Run: `grep -n "pedirRonda" resources/js/pwa/stores/sync.js` → la definición y la llamada dentro de `enviarOEncolar`.
Run: `npm run build` → OK.
Run: `bash /home/jredondo/e2e-senal.sh tests/e2e/senal-debil.spec.js`
Expected: pasan todos los pasos salvo el del crédito, que falla (todavía no manda clave: el reintento crea otro y no navega al primero).
Run: `bash /home/jredondo/e2e-senal.sh tests/e2e/cola-offline.spec.js`
Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add resources/js/pwa/views/ExpenseCreateView.vue resources/js/pwa/stores/sync.js
git commit -F /home/jredondo/commit-msg.txt
```

Mensaje: `fix(pwa): un gasto sin respuesta queda guardado; una sola puerta a la cola (PWA-002)` + Co-Authored-By.

---

### Task 10: Crédito y movimiento de socio conservan su clave

**Files:**
- Create: `resources/js/pwa/utils/claveOperacion.js`
- Modify: `resources/js/pwa/views/CreditCreateView.vue` (imports; `handleSubmit`, líneas 654-689)
- Modify: `resources/js/pwa/views/PartnerTransactionView.vue` (imports; `submit`, líneas 286-317)

- [ ] **Step 1: Crear `resources/js/pwa/utils/claveOperacion.js`**

```js
import { generateUUID } from './uuid'

// Clave de idempotencia de una operación que NO va a la cola (crédito, movimiento
// de socio). Nace en el primer envío y vive hasta que el servidor responde: un
// reintento —aunque sea tras salir y volver, o tras volver a entrar— sale con la
// misma, y el servidor devuelve el original en vez de crear otro (PWA-002).
const PREFIJO = 'credify:clave:'

// Respaldo si sessionStorage no está disponible (modo privado, bloqueado): dura
// lo que la página, que cubre al menos el reintento en el mismo formulario.
const enMemoria = new Map()

export function claveDeOperacion(operacion) {
    try {
        let clave = sessionStorage.getItem(PREFIJO + operacion)
        if (!clave) {
            clave = generateUUID()
            sessionStorage.setItem(PREFIJO + operacion, clave)
        }
        return clave
    } catch {
        if (!enMemoria.has(operacion)) enMemoria.set(operacion, generateUUID())
        return enMemoria.get(operacion)
    }
}

/** El servidor respondió (creado, duplicado o rechazo real): la próxima vez es otra operación. */
export function cerrarOperacion(operacion) {
    try {
        sessionStorage.removeItem(PREFIJO + operacion)
    } catch {
        // Sin sessionStorage: solo queda la copia en memoria.
    }
    enMemoria.delete(operacion)
}
```

- [ ] **Step 2: `CreditCreateView.vue`**

Imports, después de `import { useToast } from '../composables/useToast'`:

```js
import { useToast } from '../composables/useToast'
import { claveDeOperacion, cerrarOperacion } from '../utils/claveOperacion'
import { esFalloDeTransporte } from '../utils/transporte'
```

Reemplazar `handleSubmit` completo por:

```js
async function handleSubmit() {
    if (!canSubmit.value || submitting.value) return

    clearErrors()
    submitting.value = true

    // La clave vive hasta que el servidor responda (PWA-002): si la respuesta se
    // pierde, el reintento sale con la misma y el servidor devuelve el crédito ya
    // creado en vez de crear otro, con otro desembolso.
    const operacion = `credito:${form.client_id || clientId.value}`
    const idempotencyKey = claveDeOperacion(operacion)

    try {
        const response = await api.post('/pwa/credits', { ...buildPayload(), idempotency_key: idempotencyKey })
        cerrarOperacion(operacion)

        toast.success(response.data.duplicate ? 'Este crédito ya estaba creado' : 'Crédito creado correctamente')

        if (response.data.credit?.id) {
            router.push(`/pwa/credits/${response.data.credit.id}`)
        } else {
            router.back()
        }
    } catch (err) {
        console.error('[CreditCreate] Error', err)

        if (esFalloDeTransporte(err)) {
            // No se sabe si se creó: la clave se conserva y reintentar es seguro.
            error.value = err.response
                ? `${err.response.data?.message || 'Error del servidor.'} Puedes reintentar: no se duplica.`
                : 'No hubo respuesta del servidor. Puedes reintentar: si ya se había creado, no se duplica.'
            toast.error(error.value)
            return
        }

        cerrarOperacion(operacion)

        if (err.response?.data?.errors) {
            const backendErrors = err.response.data.errors
            Object.keys(backendErrors).forEach(key => {
                if (Object.prototype.hasOwnProperty.call(errors, key)) {
                    errors[key] = Array.isArray(backendErrors[key])
                        ? backendErrors[key][0]
                        : backendErrors[key]
                }
            })
        }

        error.value = err.response?.data?.message || 'Error al crear el crédito'
        toast.error(error.value)
    } finally {
        submitting.value = false
    }
}
```

- [ ] **Step 3: `PartnerTransactionView.vue`**

Imports, después de `import SimplifiedAmountInput from '../components/forms/SimplifiedAmountInput.vue'`:

```js
import SimplifiedAmountInput from '../components/forms/SimplifiedAmountInput.vue'
import { claveDeOperacion, cerrarOperacion } from '../utils/claveOperacion'
import { esFalloDeTransporte } from '../utils/transporte'
```

Reemplazar `submit()` completo por:

```js
async function submit() {
    if (!isFormValid.value || !isOnline.value) return
    if (isSimplifiedEnabled.value && requireConfirmation.value && !amountConfirmed.value) return
    submitting.value = true
    serverError.value = null

    // La clave vive hasta que el servidor responda (PWA-002): si la respuesta se
    // pierde, el reintento no registra el movimiento dos veces.
    const operacion = `socio:${props.id}:${form.value.transaction_type}`
    const idempotencyKey = claveDeOperacion(operacion)

    try {
        const payload = {
            transaction_type: form.value.transaction_type,
            amount:           form.value.amount,
            method:           form.value.method || null,
            date:             form.value.date,
            notes:            form.value.notes || null,
            is_simplified_amount:        isSimplifiedEnabled.value,
            simplified_amount_confirmed: amountConfirmed.value,
            idempotency_key:  idempotencyKey,
        }

        if (form.value.transaction_type === 'contribution') {
            payload.contribution_type = form.value.contribution_type
        } else {
            payload.withdrawal_type = form.value.withdrawal_type
        }

        const result = await store.storeTransaction(Number(props.id), payload)
        cerrarOperacion(operacion)
        // Un duplicado trae "Este movimiento ya estaba registrado." del servidor.
        toast.value = result.message

        setTimeout(() => router.push('/pwa/partners'), 1800)
    } catch (e) {
        if (esFalloDeTransporte(e)) {
            serverError.value = e.response
                ? `${e.response.data?.message || 'Error del servidor.'} Puedes reintentar: no se duplica.`
                : 'No hubo respuesta del servidor. Puedes reintentar: si ya se había registrado, no se duplica.'
        } else {
            cerrarOperacion(operacion)
            serverError.value = e.response?.data?.message || 'Error al registrar. Intenta de nuevo.'
        }
        submitting.value = false
    }
}
```

- [ ] **Step 4: Build y E2E completos**

Run: `npm run build` → OK.
Run: `bash /home/jredondo/e2e-senal.sh tests/e2e/senal-debil.spec.js`
Expected: PASS (todos los pasos).
Run: `bash /home/jredondo/e2e-senal.sh`
Expected: toda la suite en chromium PASS.

- [ ] **Step 5: Commit**

```bash
git add resources/js/pwa/utils/claveOperacion.js resources/js/pwa/views/CreditCreateView.vue resources/js/pwa/views/PartnerTransactionView.vue
git commit -F /home/jredondo/commit-msg.txt
```

Mensaje: `fix(pwa): crédito y movimiento de socio reintentan con la misma clave (PWA-002)` + Co-Authored-By.

---

### Task 11: CHANGELOG, estado del spec y verificación final

**Files:**
- Modify: `CHANGELOG.md` (primera entrada de `### Corregido` dentro de `## [Sin publicar]`)
- Modify: `docs/superpowers/specs/2026-09-28-senal-debil-design.md` (línea `**Estado:**`)

- [ ] **Step 1: Entrada del CHANGELOG**

Como primer ítem bajo `### Corregido` (inmediatamente después de esa línea):

```markdown
- **Con señal débil, un cobro, una visita "No paga" o un gasto ya no se pierden, y un crédito o un movimiento de socio ya no se duplican al reintentar (PWA-002).** En la calle lo normal es una señal que el teléfono da por buena y que no llega al servidor. La app solo usaba la cola cuando el teléfono se declaraba sin conexión: el cobro esperaba hasta 30 s, mostraba un error y no quedaba en ninguna parte, y si el cobrador insistía desde otra pantalla salía con otra clave y, si el primero sí había llegado, se cobraba dos veces. Ahora cobro, visita y gasto se guardan en la cola **antes** de salir, con la misma clave con que se envían, y solo se sueltan cuando el servidor responde: si no responde en 10 s quedan guardados y los sube el motor de PWA-001; si el primer envío sí había llegado, el servidor lo reconoce por la clave. Una marca en la fila evita que el motor mande lo que la pantalla está enviando, y vence sola si la app muere a mitad. Si IndexedDB no puede guardar, con señal se envía directo como antes. El GPS se pide al abrir la pantalla y no al confirmar, y la pantalla de pago avisa si ya hay un cobro de ese crédito esperando señal. El gasto con conexión, que ignoraba la clave, ahora deduplica. Crédito y movimiento de socio, que no van a la cola, llevan una clave nueva (`idempotency_key` con índice único por empresa en `credits`, `capital_contributions` y `partner_withdrawals`) que el teléfono conserva hasta que el servidor responde: el reintento devuelve el original en vez de crear otro crédito con otro desembolso. El chequeo va antes del límite del plan y del saldo del socio, para que el reintento de lo ya creado no se rechace por su propio efecto. Migración reversible; la base del teléfono no cambia de versión.
```

- [ ] **Step 2: Estado del spec**

```markdown
- **Estado:** implementado en la rama `fix/pwa-senal-debil`; falta PR y despliegue.
```

- [ ] **Step 2b: Zona horaria de toda la suite E2E**

Agregado tras la revisión de la Task 5: el servidor compara fechas contra `America/Bogota` (`config/app.php`) y el navegador de Playwright usa la zona del runner (UTC en CI). De noche el navegador manda la fecha de mañana y el servidor rechaza cobros, visitas y gastos por "fecha futura". `senal-debil.spec.js` ya lo fija en su `test.use`; `cola-offline.spec.js` tiene la misma exposición. En `playwright.config.js`, dentro de `use`, después de `screenshot: 'only-on-failure',`:

```js
        screenshot: 'only-on-failure',
        // "Hoy" es el de America/Bogota (config/app.php). Con la zona del runner
        // (UTC en CI) el navegador manda de noche la fecha de mañana y el servidor
        // rechaza cobros, visitas y gastos por "fecha futura".
        timezoneId: 'America/Bogota',
```

Sumar `playwright.config.js` al commit del Step 4.

- [ ] **Step 3: Verificación completa antes de pushear**

Run: `./vendor/bin/phpstan analyse --level=5 app/ --no-progress` → `[OK] No errors`.
Run: `./vendor/bin/pint --test` → sin archivos por corregir (si los hay, correr `./vendor/bin/pint` sobre esos archivos y sumarlos al commit).
Run: `php artisan test --compact` → todo en verde, salvo los flaky ya listados en el CHANGELOG (cada uno debe pasar aislado: comprobarlo).
Run: `npm run build` → OK.
Run: `bash /home/jredondo/e2e-senal.sh` → toda la suite en chromium PASS.
Run: `git status --short` → lo único que puede aparecer es `public/css/filament/*` y `public/js/filament/*` (ruido de `composer install`, no se commitean). `public/build` y `public/pwa-sw.js` están en `.gitignore`. Ningún otro archivo sin commitear.

- [ ] **Step 4: Commit**

```bash
git add CHANGELOG.md docs/superpowers/specs/2026-09-28-senal-debil-design.md playwright.config.js
git commit -F /home/jredondo/commit-msg.txt
```

Mensaje: `docs: CHANGELOG y estado del spec de señal débil; zona horaria de Bogotá en los E2E (PWA-002)` + Co-Authored-By.
