# Anular pagos desde la PWA — Diseño

**Fecha:** 2026-07-19
**Estado:** Aprobado (pendiente de plan de implementación)

## Objetivo

Permitir **anular un pago** desde la PWA, en el detalle del crédito
(`/pwa/credits/:id` → componente `PaymentHistory`). Hoy la PWA registra y lista pagos,
pero no puede anularlos; solo el panel `/admin` puede (PR #198). Se reutiliza el **mismo
mecanismo** para que ambas superficies se comporten idéntico.

## Decisiones (acordadas)

- **Permiso:** **solo `admin`** (supervisor y cobrador NO anulan desde la PWA).
- **Solo-online** (igual que editar clientes / que el panel admin). El botón se oculta sin
  conexión; no hay cola offline para anular.
- **Comportamiento A:** tras anular, el pago **desaparece** de la lista y el saldo/cuotas
  del crédito se actualizan. (No se muestra el pago tachado con badge «Anulado».)
- **"Anular" = REVERSAR**, nunca borrado físico: reutiliza
  `PaymentManager::canDeletePayment` + `deletePayment` (crea el asiento de reversión,
  marca `voided=1`, re-materializa cuotas), idéntico al panel admin (#198).
- **Motivo obligatorio** (auditoría), como en admin.

## Alcance

### Backend

1. **Permiso `canVoidPayments`** (nuevo en `RoleAwareQueries`): `getUserPwaRole($request)
   === 'admin'` (solo admin). Se emite además `can_void_payments` en
   `getUserPermissions()` (para el frontend).

2. **`PaymentController::void(Request $request, int $id): JsonResponse`**:
   - **403** si `! canVoidPayments`.
   - Resolver el pago **acotado por empresa**: `Payment::where('company_id',
     $user->company_id)->find($id)`; si no existe → **404**.
   - Validar `reason` (`required|string|max:500`) → **422** si falta.
   - Si `! $this->paymentManager->canDeletePayment($payment)` → **422** con mensaje
     («Este pago no se puede anular» — ya anulado, crédito cerrado o auditoría crítica).
   - `$this->paymentManager->deletePayment($payment, $reason)`.
   - Responder `{ message: 'Pago anulado correctamente.' }`.

3. **Ruta:** `POST /api/pwa/payments/{id}/void` → `payments.void`, con
   `->middleware('throttle:pwa-write')` y `->whereNumber('id')` (como las rutas hermanas).

4. **Limpieza de la lista:** en `CreditController::payments()`, excluir las **reversas**
   (`->where('payment_method', '!=', 'reversal')`) además del filtro `voided=false` ya
   existente, para que un pago anulado (desde admin o PWA) no aparezca como un monto
   **negativo** en el historial. Sin este filtro, el asiento de reversión se colaría.

### Frontend

1. **`PaymentHistory.vue`** (dentro del detalle del crédito):
   - Botón **«Anular»** por pago, visible **solo si** `authStore.canVoidPayments` **y**
     hay conexión (`navigator.onLine`, con listeners `online`/`offline`). No se muestra en
     pagos pendientes de sincronizar (`_pending`).
   - Al pulsar: modal/confirm con un campo **motivo** (obligatorio).
   - Al confirmar: `POST /pwa/payments/{id}/void { reason }`.
   - Al éxito: toast, **re-consulta** su propia lista (`fetchPayments`) y **emite `voided`**
     para que el detalle refresque el crédito. Manejo de 403/404/422/genérico.

2. **`CreditDetailView.vue`**: `<PaymentHistory :credit-id="credit.id" @voided="fetchCredit" />`
   — al anular, `fetchCredit()` re-consulta el crédito (saldo/cuotas actualizados).

3. **`auth store`**: `canVoidPayments` computed
   (`permissions.value.can_void_payments ?? userRole.value === 'admin'`) + exportarlo.

## Flujo de datos

1. Admin abre el detalle de un crédito (online) → `PaymentHistory` muestra «Anular» por pago.
2. Pulsa «Anular» → modal de motivo → `POST /pwa/payments/:id/void`.
3. Backend: 403 (rol) / 404 (empresa) / 422 (motivo o `canDeletePayment`) / 200.
   En 200: `deletePayment` crea la reversa, marca `voided`, re-materializa cuotas.
4. Frontend: toast → `fetchPayments()` (el pago anulado ya no vuelve, el endpoint filtra
   `voided` + reversas) → emite `voided` → `CreditDetailView.fetchCredit()` actualiza saldo.

## Seguridad / multi-tenant

- El pago se resuelve **siempre** acotado por `company_id` del usuario → un id ajeno da 404.
- El gate del frontend (`canVoidPayments`) es solo UX; el gate real es el backend
  (403 por rol, 404 por empresa, 422 por reglas de `canDeletePayment`).
- `deletePayment` corre bajo el `PaymentReverser` existente (transacción + auditoría).

## Testing

**Backend (feature tests, `tests/Feature/Pwa/`):**
- admin anula un pago efectivo → 200; el pago queda `voided=1` y se crea el asiento de
  reversión (`payment_method='reversal'`, `original_payment_id`).
- supervisor y cobrador → **403** (no anulan).
- pago de otra empresa → **404**.
- `reason` faltante → **422**.
- pago ya anulado (o de crédito cerrado) → **422** (bloqueado por `canDeletePayment`).
- `/credits/{id}/payments` **no** incluye el asiento de reversión tras anular (lista limpia).

**Frontend:** sin infra de tests JS → `npm run build` + revisión en navegador (botón solo
para admin y solo online; anular quita el pago y baja el saldo).

## Fuera de alcance

- Anular **offline** (con cola de sincronización).
- Anular desde el panel `/admin` (ya existe, #198).
- Mostrar el pago anulado tachado con badge «Anulado» (comportamiento B).
- Editar pagos desde la PWA (solo anular).
