# Anular pagos desde la PWA — Implementation Plan

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

**Goal:** Permitir que un `admin` anule un pago desde la PWA (detalle del crédito), solo-online, con reversa auditada; el pago anulado desaparece del historial y el saldo se actualiza.

**Architecture:** Nuevo endpoint `POST /api/pwa/payments/{id}/void` (solo admin, acotado por empresa, motivo obligatorio) que reutiliza `PaymentManager::canDeletePayment` + `deletePayment` (el mismo mecanismo del panel `/admin`, #198). En el frontend, `PaymentHistory` gana un botón «Anular» (admin + online) con modal de motivo que refresca la lista y el crédito. Además se excluyen los asientos de reversión del historial PWA.

**Tech Stack:** Laravel 13 / PHP 8.3, Sanctum; PWA en Vue 3 (`<script setup>`) + Pinia; tests backend con PHPUnit + Sanctum. Spec: `docs/superpowers/specs/2026-07-19-pwa-void-payment-design.md`.

---

## File Structure

**Backend**
- Modify `app/Http/Controllers/Api/Pwa/Traits/RoleAwareQueries.php` — add `canVoidPayments()` + `can_void_payments` in `getUserPermissions()`.
- Modify `app/Http/Controllers/Api/Pwa/PaymentController.php` — add `void()`.
- Modify `app/Http/Controllers/Api/Pwa/CreditController.php` — exclude reversals in `payments()`.
- Modify `routes/api.php` — add `POST /payments/{id}/void`.
- Create `tests/Feature/Pwa/VoidPaymentTest.php`.

**Frontend**
- Modify `resources/js/pwa/stores/auth.js` — add `canVoidPayments`.
- Modify `resources/js/pwa/components/credits/PaymentHistory.vue` — «Anular» button + modal + call + refresh + `voided` emit.
- Modify `resources/js/pwa/views/CreditDetailView.vue` — `@voided="fetchCredit"`.

---

## Task 1: Backend — void endpoint (permission, tenancy, reason, reversal exclusion)

**Files:**
- Modify: `app/Http/Controllers/Api/Pwa/Traits/RoleAwareQueries.php`
- Modify: `app/Http/Controllers/Api/Pwa/PaymentController.php`
- Modify: `app/Http/Controllers/Api/Pwa/CreditController.php`
- Modify: `routes/api.php`
- Test: `tests/Feature/Pwa/VoidPaymentTest.php`

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

Create `tests/Feature/Pwa/VoidPaymentTest.php`:

```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\Installment;
use App\Models\Payment;
use App\Models\Plan;
use App\Models\Subscription;
use App\Models\User;
use App\Services\PaymentManager;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Event;
use Laravel\Sanctum\Sanctum;
use PHPUnit\Framework\Attributes\Test;
use Spatie\Permission\Models\Role;
use Tests\TestCase;

class VoidPaymentTest extends TestCase
{
    use DatabaseTransactions;

    private Company $company;

    private User $admin;

    private User $supervisor;

    private User $collector;

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

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

        $plan = Plan::factory()->create(['has_pwa_access' => true]);
        $this->company = Company::factory()->create(['interest_method' => 'flat_rate']);
        Subscription::factory()->active()->create([
            'company_id' => $this->company->id,
            'plan_id' => $plan->id,
            'ends_at' => now()->addYear(),
        ]);

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

        $this->supervisor = User::factory()->create(['company_id' => $this->company->id]);
        $this->supervisor->assignRole('supervisor');
        $this->supervisor = $this->supervisor->fresh();

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

    /**
     * Crea un crédito con cuotas y un pago EFECTIVO ya registrado (distribuido).
     *
     * @return array{0: Credit, 1: Payment}
     */
    private function creditWithPayment(): array
    {
        $client = Client::factory()->create(['company_id' => $this->company->id]);

        $credit = Credit::create([
            'company_id' => $this->company->id,
            'client_id' => $client->id,
            'created_by_user_id' => $this->admin->id,
            'collector_user_id' => $this->collector->id,
            'status' => Credit::STATUS_ACTIVE,
            'amount' => 100000,
            'interest_rate' => 20,
            'installments_count' => 4,
            'periodicity' => 'weekly',
            'start_date' => Carbon::now(),
        ]);

        for ($i = 1; $i <= 4; $i++) {
            Installment::create([
                'company_id' => $this->company->id,
                'credit_id' => $credit->id,
                'installment_number' => $i,
                'status' => Installment::STATUS_PENDING,
                'due_date' => Carbon::now()->addWeeks($i),
                'principal_amount' => 25000,
                'interest_amount' => 5000,
                'total_amount' => 30000,
                'balance_due' => 30000,
                'amount_paid' => 0,
            ]);
        }

        $payment = app(PaymentManager::class)->registerPaymentForCredit(
            $credit->fresh(), 30000, Carbon::now(), $this->admin->id, 'cash',
        );

        return [$credit->fresh(), $payment];
    }

    #[Test]
    public function an_admin_can_void_a_payment(): void
    {
        [$credit, $payment] = $this->creditWithPayment();
        Sanctum::actingAs($this->admin);

        $this->postJson("/api/pwa/payments/{$payment->id}/void", [
            'reason' => 'Pago mal registrado',
        ])->assertStatus(200);

        $this->assertTrue((bool) $payment->fresh()->voided, 'el pago queda anulado');
        $this->assertDatabaseHas('payments', [
            'credit_id' => $credit->id,
            'payment_method' => 'reversal',
            'original_payment_id' => $payment->id,
        ]);
    }

    #[Test]
    public function a_supervisor_cannot_void_a_payment(): void
    {
        [$credit, $payment] = $this->creditWithPayment();
        Sanctum::actingAs($this->supervisor);

        $this->postJson("/api/pwa/payments/{$payment->id}/void", ['reason' => 'x'])
            ->assertStatus(403);

        $this->assertFalse((bool) $payment->fresh()->voided);
    }

    #[Test]
    public function a_collector_cannot_void_a_payment(): void
    {
        [$credit, $payment] = $this->creditWithPayment();
        Sanctum::actingAs($this->collector);

        $this->postJson("/api/pwa/payments/{$payment->id}/void", ['reason' => 'x'])
            ->assertStatus(403);

        $this->assertFalse((bool) $payment->fresh()->voided);
    }

    #[Test]
    public function a_payment_from_another_company_returns_404(): void
    {
        [$credit, $payment] = $this->creditWithPayment();
        $otherCompany = Company::factory()->create();
        $otherAdmin = User::factory()->create(['company_id' => $otherCompany->id]);
        $otherAdmin->assignRole('admin');
        Sanctum::actingAs($otherAdmin->fresh());

        $this->postJson("/api/pwa/payments/{$payment->id}/void", ['reason' => 'x'])
            ->assertStatus(404);

        $this->assertFalse((bool) $payment->fresh()->voided);
    }

    #[Test]
    public function the_reason_is_required(): void
    {
        [$credit, $payment] = $this->creditWithPayment();
        Sanctum::actingAs($this->admin);

        $this->postJson("/api/pwa/payments/{$payment->id}/void", [])
            ->assertStatus(422)
            ->assertJsonValidationErrors(['reason']);

        $this->assertFalse((bool) $payment->fresh()->voided);
    }

    #[Test]
    public function an_already_voided_payment_cannot_be_voided_again(): void
    {
        [$credit, $payment] = $this->creditWithPayment();
        Sanctum::actingAs($this->admin);

        $this->postJson("/api/pwa/payments/{$payment->id}/void", ['reason' => 'primera'])
            ->assertStatus(200);

        $this->postJson("/api/pwa/payments/{$payment->id}/void", ['reason' => 'otra vez'])
            ->assertStatus(422);
    }

    #[Test]
    public function the_credit_payments_endpoint_excludes_reversal_entries(): void
    {
        [$credit, $payment] = $this->creditWithPayment();
        Sanctum::actingAs($this->admin);

        $this->postJson("/api/pwa/payments/{$payment->id}/void", ['reason' => 'anulado'])
            ->assertStatus(200);

        // Ni el pago anulado (voided) ni el asiento de reversión aparecen.
        $this->getJson("/api/pwa/credits/{$credit->id}/payments")
            ->assertStatus(200)
            ->assertJsonPath('data', []);
    }
}
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `wsl bash -lc "cd /var/www/html/credify && php artisan test tests/Feature/Pwa/VoidPaymentTest.php"`
Expected: the 7 tests FAIL (route `POST /payments/{id}/void` returns 405/404).

- [ ] **Step 3: Add `canVoidPayments()` to the trait**

In `app/Http/Controllers/Api/Pwa/Traits/RoleAwareQueries.php`, right after the `canEditClients()` method (which ends around line 168), add:

```php
    /**
     * Verifica si el usuario puede ANULAR pagos desde la PWA.
     * Solo admin (operacion financiera sensible: revierte un cobro).
     */
    protected function canVoidPayments(Request $request): bool
    {
        return $this->getUserPwaRole($request) === 'admin';
    }
```

- [ ] **Step 4: Emit `can_void_payments` in `getUserPermissions()`**

In the same file, inside the `getUserPermissions()` return array, add the key right after `'can_register_payments' => ...,`:

```php
            'can_register_payments' => in_array($role, ['collector', 'admin'], true),
            'can_void_payments' => $role === 'admin',
```

- [ ] **Step 5: Add the `void()` method to `PaymentController`**

`PaymentController` already imports `Payment`, uses `RoleAwareQueries`, and injects `private readonly PaymentManager $paymentManager` via its constructor. Add this method (e.g. right after `store()`):

```php
    /**
     * POST /api/pwa/payments/{id}/void
     *
     * Anula (reversa) un pago. Solo admin. "Anular" = REVERSAR con auditoria
     * (mismo mecanismo que el panel /admin), nunca un borrado fisico.
     */
    public function void(Request $request, int $id): JsonResponse
    {
        if (! $this->canVoidPayments($request)) {
            return response()->json([
                'message' => 'No tienes permiso para anular pagos.',
            ], 403);
        }

        $user = $request->user();

        $payment = Payment::where('company_id', $user->company_id)->find($id);

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

        $data = $request->validate([
            'reason' => ['required', 'string', 'max:500'],
        ]);

        if (! $this->paymentManager->canDeletePayment($payment)) {
            return response()->json([
                'message' => 'Este pago no se puede anular (ya anulado, credito cerrado o con auditoria critica).',
            ], 422);
        }

        $this->paymentManager->deletePayment($payment, $data['reason']);

        return response()->json([
            'message' => 'Pago anulado correctamente.',
        ]);
    }
```

- [ ] **Step 6: Register the route**

In `routes/api.php`, right after the `payments.store` route (`POST /payments` with `throttle:pwa-write`), add:

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

- [ ] **Step 7: Exclude reversal entries from the credit payments list**

In `app/Http/Controllers/Api/Pwa/CreditController.php`, inside `payments()`, add the `payment_method` filter to the payments query. Change:

```php
        $payments = $credit->payments()
            ->where('voided', false)
            ->with(['registeredBy:id,name', 'installments'])
```

to:

```php
        $payments = $credit->payments()
            ->where('voided', false)
            ->where('payment_method', '!=', 'reversal')
            ->with(['registeredBy:id,name', 'installments'])
```

- [ ] **Step 8: Run tests to verify they pass**

Run: `wsl bash -lc "cd /var/www/html/credify && php artisan test tests/Feature/Pwa/VoidPaymentTest.php"`
Expected: all 7 tests PASS.

- [ ] **Step 9: Static analysis + style**

Run: `wsl bash -lc "cd /var/www/html/credify && vendor/bin/pint app/Http/Controllers/Api/Pwa/PaymentController.php app/Http/Controllers/Api/Pwa/CreditController.php app/Http/Controllers/Api/Pwa/Traits/RoleAwareQueries.php routes/api.php tests/Feature/Pwa/VoidPaymentTest.php && vendor/bin/phpstan analyse app/Http/Controllers/Api/Pwa/PaymentController.php app/Http/Controllers/Api/Pwa/CreditController.php app/Http/Controllers/Api/Pwa/Traits/RoleAwareQueries.php --no-progress"`
Expected: Pint PASS, PHPStan `[OK] No errors`.

- [ ] **Step 10: Commit**

```bash
wsl bash -lc "cd /var/www/html/credify && git add app/Http/Controllers/Api/Pwa/PaymentController.php app/Http/Controllers/Api/Pwa/CreditController.php app/Http/Controllers/Api/Pwa/Traits/RoleAwareQueries.php routes/api.php tests/Feature/Pwa/VoidPaymentTest.php && git commit -m 'feat(pwa): endpoint POST /payments/{id}/void (solo admin) + excluir reversas del historial'"
```

---

## Task 2: Frontend — `canVoidPayments` in the auth store

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

> No JS test framework — verify via `npm run build`.

- [ ] **Step 1: Add the computed permission**

In `resources/js/pwa/stores/auth.js`, right after the `canEditClients` computed (around line 58-60), add:

```javascript
    const canVoidPayments = computed(() =>
        permissions.value.can_void_payments ?? userRole.value === 'admin'
    )
```

- [ ] **Step 2: Export it from the store**

In the store's `return { ... }` object, add `canVoidPayments` right after `canEditClients,`:

```javascript
        canEditClients,
        canVoidPayments,
```

- [ ] **Step 3: Build to verify no errors**

Run: `wsl bash -lc "cd /var/www/html/credify && npm run build 2>&1 | tail -6"`
Expected: build succeeds.

- [ ] **Step 4: Commit**

```bash
wsl bash -lc "cd /var/www/html/credify && git add resources/js/pwa/stores/auth.js && git commit -m 'feat(pwa): permiso canVoidPayments (solo admin) en el auth store'"
```

---

## Task 3: Frontend — «Anular» button + modal in `PaymentHistory`

**Files:**
- Modify: `resources/js/pwa/components/credits/PaymentHistory.vue`

- [ ] **Step 1: Replace the full component**

Replace the ENTIRE contents of `resources/js/pwa/components/credits/PaymentHistory.vue` with:

```vue
<template>
    <div class="rounded-2xl overflow-hidden pwa-card">
        <div class="px-4 py-3 flex justify-between items-center" style="border-bottom: 1px solid rgba(255,255,255,0.06);">
            <h3 class="text-sm font-semibold text-slate-400">Historial de Pagos</h3>
            <button
                v-if="!loading && payments.length > 0"
                @click="showAll = !showAll"
                class="text-xs text-emerald-400 font-medium"
            >
                {{ showAll ? 'Ver menos' : 'Ver todos' }}
            </button>
        </div>

        <!-- Loading -->
        <div v-if="loading" class="px-4 py-6 flex justify-center">
            <svg class="animate-spin w-6 h-6 text-emerald-400" fill="none" viewBox="0 0 24 24">
                <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
                <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
            </svg>
        </div>

        <!-- Payments list -->
        <div v-else-if="payments.length > 0" class="divide-y" style="border-color: rgba(255,255,255,0.05);">
            <div
                v-for="payment in displayedPayments"
                :key="payment.id ?? payment.payment_date + payment.amount"
                class="px-4 py-3"
            >
                <div class="flex justify-between items-start">
                    <div>
                        <p class="font-semibold text-slate-100">
                            ${{ formatNumber(payment.amount) }}
                        </p>
                        <p class="text-slate-500 text-xs mt-0.5">
                            {{ formatDate(payment.payment_date) }}
                        </p>
                    </div>
                    <div class="text-right">
                        <span
                            class="text-xs px-2 py-0.5 rounded-full"
                            :class="payment._pending
                                ? 'bg-amber-500/15 text-amber-400'
                                : 'bg-slate-500/15 text-slate-400'"
                        >
                            {{ payment._pending ? 'Pendiente' : payment.payment_method_label }}
                        </span>
                        <p class="text-slate-600 text-xs mt-1">
                            {{ payment.registered_by }}
                        </p>
                    </div>
                </div>

                <!-- Cuotas afectadas -->
                <div v-if="payment.installments_affected?.length > 0" class="mt-2 flex flex-wrap gap-1">
                    <span
                        v-for="inst in payment.installments_affected"
                        :key="inst.number"
                        class="text-xs bg-emerald-500/10 text-emerald-400 px-1.5 py-0.5 rounded-sm"
                    >
                        #{{ inst.number }}: ${{ formatNumber(inst.amount_applied) }}
                    </span>
                </div>

                <!-- Anular (solo admin, solo online) -->
                <div v-if="canVoid(payment)" class="mt-2 flex justify-end">
                    <button
                        type="button"
                        @click="openVoidModal(payment)"
                        class="text-xs font-medium text-red-400 border border-red-500/40 rounded-lg px-2.5 py-1 active:bg-red-500/10"
                    >
                        Anular
                    </button>
                </div>
            </div>
        </div>

        <!-- Empty state -->
        <div v-else class="px-4 py-6 text-center text-slate-500 text-sm">
            No hay pagos registrados
        </div>

        <!-- Total -->
        <div v-if="payments.length > 0" class="px-4 py-3" style="background: rgba(255,255,255,0.03); border-top: 1px solid rgba(255,255,255,0.06);">
            <div class="flex justify-between items-center">
                <span class="text-sm text-slate-500">Total pagado</span>
                <span class="font-bold text-emerald-400">
                    ${{ formatNumber(totalAmount) }}
                </span>
            </div>
        </div>
    </div>

    <!-- Void modal -->
    <div
        v-if="voidingPayment"
        class="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4"
        style="background: rgba(0,0,0,0.6);"
        @click.self="closeVoidModal"
    >
        <div class="w-full max-w-sm rounded-2xl p-5 pwa-card" @click.stop>
            <h3 class="text-base font-semibold text-slate-100">Anular pago</h3>
            <p class="text-sm text-slate-400 mt-1">
                Anularás el pago de ${{ formatNumber(voidingPayment.amount) }} del
                {{ formatDate(voidingPayment.payment_date) }}. Se registrará una reversión y el
                saldo del crédito se actualizará.
            </p>
            <label class="text-xs font-bold text-slate-500 uppercase tracking-widest block mt-4 mb-2">Motivo *</label>
            <textarea
                v-model="voidReason"
                rows="3"
                maxlength="500"
                :disabled="voidSubmitting"
                placeholder="Ej.: pago mal registrado"
                class="w-full rounded-xl px-3 py-2.5 text-sm resize-none pwa-input"
            ></textarea>
            <div v-if="voidError" class="mt-2 text-sm text-red-400">{{ voidError }}</div>
            <div class="mt-4 flex gap-2">
                <button
                    type="button"
                    @click="closeVoidModal"
                    :disabled="voidSubmitting"
                    class="flex-1 py-2.5 rounded-xl text-sm font-medium text-slate-300 bg-white/5 border border-white/10"
                >
                    Cancelar
                </button>
                <button
                    type="button"
                    @click="confirmVoid"
                    :disabled="voidSubmitting || !voidReason.trim()"
                    class="flex-1 py-2.5 rounded-xl text-sm font-bold text-white disabled:opacity-40"
                    style="background: #dc2626;"
                >
                    <span v-if="voidSubmitting">Anulando...</span>
                    <span v-else>Anular pago</span>
                </button>
            </div>
        </div>
    </div>
</template>

<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import api from '../../services/api'
import { db } from '../../db'
import { formatDisplayDate } from '../../utils/dates'
import { useAuthStore } from '../../stores/auth'
import { useToast } from '../../composables/useToast'

const props = defineProps({
    creditId: {
        type: [Number, String],
        required: true
    }
})

const emit = defineEmits(['voided'])

const authStore = useAuthStore()
const toast = useToast()

const loading = ref(true)
const payments = ref([])
const showAll = ref(false)

const isOnline = ref(navigator.onLine)
const onOnline = () => { isOnline.value = true }
const onOffline = () => { isOnline.value = false }
window.addEventListener('online', onOnline)
window.addEventListener('offline', onOffline)
onUnmounted(() => {
    window.removeEventListener('online', onOnline)
    window.removeEventListener('offline', onOffline)
})

const displayedPayments = computed(() => {
    if (showAll.value) {
        return payments.value
    }
    return payments.value.slice(0, 3)
})

const totalAmount = computed(() => {
    return payments.value.reduce((sum, p) => sum + (p.amount || 0), 0)
})

// Solo admin, solo online, solo pagos ya sincronizados (con id, no pendientes).
function canVoid(payment) {
    return authStore.canVoidPayments && isOnline.value && !payment._pending && !!payment.id
}

async function fetchPayments() {
    loading.value = true
    try {
        if (navigator.onLine) {
            const response = await api.get(`/pwa/credits/${props.creditId}/payments`)
            payments.value = response.data.data || []
        } else {
            await loadFromLocal()
        }
    } catch (err) {
        console.error('[PaymentHistory] Fetch error', err)
        await loadFromLocal()
    } finally {
        loading.value = false
    }
}

const METHOD_LABELS = {
    cash: 'Efectivo', transfer: 'Transferencia', mobile: 'Pago móvil', card: 'Tarjeta',
}

async function loadFromLocal() {
    const creditId = parseInt(props.creditId)

    // Pagos sincronizados almacenados durante syncData().
    // Con Fix 3 (backend) ahora incluyen payment_method_label y registered_by.
    // El fallback garantiza compatibilidad con registros anteriores al fix.
    const local = await db.payments
        .where('credit_id').equals(creditId)
        .sortBy('payment_date')

    const normalizedLocal = local.reverse().map(p => ({
        ...p,
        payment_method_label: p.payment_method_label || METHOD_LABELS[p.payment_method] || p.payment_method || '',
        registered_by:        p.registered_by || '',
        installments_affected: p.installments_affected || [],
    }))

    // Pagos pendientes de sincronizar para este crédito
    const pending = await db.pendingPayments
        .where('credit_id').equals(creditId)
        .toArray()

    const pendingNormalized = pending.map(p => ({
        id:                   null,
        amount:               p.amount,
        payment_date:         p.payment_date,
        payment_method_label: METHOD_LABELS[p.payment_method] || p.payment_method || '',
        registered_by:        'Pendiente de sincronizar',
        installments_affected: [],
        _pending:             true,
    }))

    payments.value = [...pendingNormalized, ...normalizedLocal]
}

// ─── Anular pago ─────────────────────────────────────────────────────────────
const voidingPayment = ref(null)
const voidReason = ref('')
const voidSubmitting = ref(false)
const voidError = ref(null)

function openVoidModal(payment) {
    voidingPayment.value = payment
    voidReason.value = ''
    voidError.value = null
}

function closeVoidModal() {
    if (voidSubmitting.value) return
    voidingPayment.value = null
}

async function confirmVoid() {
    if (!voidReason.value.trim() || voidSubmitting.value || !voidingPayment.value) return
    voidSubmitting.value = true
    voidError.value = null
    try {
        await api.post(`/pwa/payments/${voidingPayment.value.id}/void`, {
            reason: voidReason.value.trim(),
        })
        toast.success('Pago anulado correctamente')
        voidingPayment.value = null
        await fetchPayments()
        emit('voided')
    } catch (err) {
        console.error('[PaymentHistory] Void error', err)
        if (err.response?.status === 403) {
            voidError.value = 'No tienes permiso para anular pagos'
        } else if (err.response?.status === 404) {
            voidError.value = 'Pago no encontrado'
        } else if (err.response?.status === 422) {
            voidError.value = err.response.data?.message || 'Este pago no se puede anular'
        } else {
            voidError.value = err.response?.data?.message || 'Error al anular el pago'
        }
    } finally {
        voidSubmitting.value = false
    }
}

function formatNumber(num) {
    if (num === null || num === undefined) return '0'
    return new Intl.NumberFormat('es-CO', {
        minimumFractionDigits: 0,
        maximumFractionDigits: 2
    }).format(num)
}

function formatDate(dateStr) {
    return formatDisplayDate(dateStr)
}

watch(() => props.creditId, fetchPayments)

onMounted(fetchPayments)
</script>
```

- [ ] **Step 2: Build to verify no errors**

Run: `wsl bash -lc "cd /var/www/html/credify && npm run build 2>&1 | tail -8"`
Expected: build succeeds (no Vue/Vite compile errors).

- [ ] **Step 3: Commit**

```bash
wsl bash -lc "cd /var/www/html/credify && git add resources/js/pwa/components/credits/PaymentHistory.vue && git commit -m 'feat(pwa): boton Anular + modal de motivo en el historial de pagos (solo admin, online)'"
```

---

## Task 4: Frontend — refresh the credit after a void

**Files:**
- Modify: `resources/js/pwa/views/CreditDetailView.vue`

- [ ] **Step 1: Wire the `voided` event**

In `resources/js/pwa/views/CreditDetailView.vue`, find:

```vue
                <PaymentHistory :credit-id="credit.id" />
```

and change it to:

```vue
                <PaymentHistory :credit-id="credit.id" @voided="fetchCredit" />
```

(`fetchCredit()` is the existing method in this view that re-fetches `/pwa/credits/:id`.)

- [ ] **Step 2: Build to verify no errors**

Run: `wsl bash -lc "cd /var/www/html/credify && npm run build 2>&1 | tail -6"`
Expected: build succeeds.

- [ ] **Step 3: Commit**

```bash
wsl bash -lc "cd /var/www/html/credify && git add resources/js/pwa/views/CreditDetailView.vue && git commit -m 'feat(pwa): refrescar el credito tras anular un pago desde el detalle'"
```

---

## Task 5: Full verification + CHANGELOG

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

- [ ] **Step 1: Run the full PWA test suite**

Run: `wsl bash -lc "cd /var/www/html/credify && php artisan test tests/Feature/Pwa"`
Expected: all PASS (including the 7 new `VoidPaymentTest` cases).

- [ ] **Step 2: Add a CHANGELOG entry**

In `CHANGELOG.md`, under `## [Sin publicar]` → `### Añadido`, add as the first bullet:

```markdown
- **Anular pagos desde la PWA (solo admin).** En el detalle del crédito, el historial de pagos gana un botón «Anular» (visible solo para `admin` y con conexión) con modal de motivo obligatorio; llama a `POST /api/pwa/payments/{id}/void`, que reutiliza el mismo mecanismo del panel `/admin` (`PaymentManager::canDeletePayment` + `deletePayment` → reversa con auditoría, nunca borrado físico). Acotado por empresa (pago ajeno → 404); supervisor/cobrador → 403; reglas de bloqueo por `canDeletePayment` (ya anulado / crédito cerrado / auditoría crítica → 422). Tras anular, el pago desaparece del historial y el saldo/cuotas se refrescan (solo-online). De paso, `/credits/{id}/payments` **excluye los asientos de reversión** para que un pago anulado no aparezca como monto negativo. Tests `VoidPaymentTest` (7: admin anula + reversa; supervisor/cobrador 403; cross-company 404; motivo requerido; ya-anulado 422; historial sin reversas). PHPStan + Pint limpios; `npm run build` OK.
```

- [ ] **Step 3: Commit**

```bash
wsl bash -lc "cd /var/www/html/credify && git add CHANGELOG.md && git commit -m 'docs(changelog): anular pagos desde la PWA'"
```

- [ ] **Step 4: Manual browser check**

Verify in the browser: as an **admin**, open a credit with a payment → the payment shows an «Anular» button → tap → enter a reason → confirm → the payment disappears and the credit balance goes up. As a **collector/supervisor**, the button does NOT appear. Offline, the button does NOT appear.

---

## Self-Review

**Spec coverage:**
- `canVoidPayments` (admin only) + `can_void_payments` → Task 1 (Steps 3-4). ✓
- `POST /payments/{id}/void` (403/404/422/200, reason, `canDeletePayment` + `deletePayment`) → Task 1 (Steps 5-6). ✓
- Route with `throttle:pwa-write` + `whereNumber('id')` → Task 1 (Step 6). ✓
- Exclude reversals from `/credits/{id}/payments` → Task 1 (Step 7). ✓
- `canVoidPayments` in auth store → Task 2. ✓
- «Anular» button (admin + online + not pending) + reason modal + call + refresh + `voided` emit → Task 3. ✓
- `@voided="fetchCredit"` in CreditDetailView → Task 4. ✓
- Online-only → `canVoid()` checks `isOnline` (Task 3). ✓
- Tests (admin voids, supervisor/collector 403, cross-company 404, reason required, already-voided 422, reversal-excluded) → Task 1 (Step 1). ✓
- Out of scope (offline queue, admin panel #198, comportamiento B, editing payments) → not implemented. ✓

**Placeholder scan:** No TBD/TODO. All code blocks complete.

**Type/name consistency:** `canVoidPayments` used in backend (Task 1 trait, `can_void_payments` permission), auth store (Task 2), and `PaymentHistory` gate (Task 3). Route name `payments.void`; endpoint path `/pwa/payments/{id}/void` consistent between the controller/route (Task 1) and the frontend `api.post` (Task 3). `voided` event emitted in Task 3 and listened in Task 4. `$this->paymentManager->canDeletePayment/deletePayment` match the existing `PaymentManager` signatures.
