Skip to content

Bloqueo de Carta + identificación antes de pagar — diseño técnico

Date: 2026-09-12 Decisión: ADR-008

Sin migraciones nuevas: todas las columnas/tablas que esto necesita (transactions.station_id, transaction_documents) ya existen. Es lógica nueva sobre datos existentes.

1. Backend

1.1 TransactionDocument.php — nueva constante

php
// Subconjunto de REQUERIDOS_SIEMPRE que se exige ANTES de pagar (ADR-008). Excluye
// PAPELETA_FIRMADA a propósito: no puede existir todavía, se firma después del pago.
public const REQUERIDOS_ANTES_DE_PAGAR = ['CARTA', 'IDENTIFICACION_ANVERSO', 'IDENTIFICACION_REVERSO'];

1.2 Transaction.php — nuevo método, junto a faltanDocumentos()

php
public function faltanDocumentosPrePago(): array
{
    $subidos = $this->documents->pluck('type')->unique()->all();

    return array_values(array_diff(TransactionDocument::REQUERIDOS_ANTES_DE_PAGAR, $subidos));
}

1.3 TransactionDocumentController.phpauthorizeScope() ya no asume "siempre resuelta"

Reemplaza el chequeo incondicional de PDV (líneas 137-144) por la misma distinción que ya usa TransactionController::authorizeScope():

php
private function authorizeScope(Request $request, Transaction $transaction, string $action): User
{
    $user = $request->attributes->get('api_user');

    if (! $this->permissions->allows($user, self::MODULE, $action, $transaction->country_id)) {
        abort(404);
    }

    // ACCEPTED sin PDV asignado todavía: bolsa compartida, mismo criterio que
    // TransactionController::authorizeScope() para la transacción misma (ADR-008).
    if ($transaction->status === 'ACCEPTED' && $transaction->station_id === null) {
        return $user;
    }

    $stationIds = $this->permissions->allowedStationIds($user, self::MODULE, $action, $transaction->country_id);

    if ($stationIds !== null && ! in_array($transaction->station_id, $stationIds, true)) {
        abort(404);
    }

    return $user;
}

1.4 TransactionDocumentController::store() — asigna el PDV en el primer upload

Antes de $this->documents->store(...), si la transacción es ACCEPTED y no tiene station_id:

php
if ($transaction->status === 'ACCEPTED' && $transaction->station_id === null) {
    $transaction->station_id = $this->transactions->resolveStationForResolution($user, $transaction->country_id);
    $transaction->save();
}

resolveStationForResolution() hoy es private en TransactionController — pasa a un método public en un colaborador compartido (o se mueve a un service, ej. TransactionResolutionService) para no duplicar la lógica de "PDV activo, si no hay uno solo en su alcance". Evaluar en la implementación si conviene extraerlo a PermissionService (ya tiene defaultStationFor y allowedStationIds, que son las dos piezas que usa).

1.5 TransactionController::pay() — no reasignar station_id si ya lo tiene

Línea 250, $locked->station_id = $resolvedStationId; pasa a:

php
$locked->station_id ??= $resolvedStationId;

Y el corte por documentos faltantes, inmediatamente después del re-chequeo de estado (línea 199, antes de tocar prefondeo/caja):

php
$faltanPrePago = $locked->faltanDocumentosPrePago();
if ($faltanPrePago !== []) {
    return response()->json([
        'code' => 'MISSING_REQUIRED_DOCUMENTS',
        'message' => 'Faltan documentos obligatorios antes de poder confirmar el pago.',
        'faltan' => $faltanPrePago,
    ], 422);
}

1.6 Tests nuevos (TransactionApiTest.php, TransactionDocumentApiTest.php)

  • pay() rechaza con 422 MISSING_REQUIRED_DOCUMENTS si falta CARTA/IDENTIFICACION_ANVERSO/IDENTIFICACION_REVERSO.
  • pay() funciona igual que hoy si los tres están (no rompe ningún test existente de pay).
  • Subir un documento a una ACCEPTED sin PDV asignado le asigna el PDV del uploader.
  • Un segundo usuario, de otro PDV, no puede subir a una ACCEPTED que ya tiene PDV asignado por otro operador (404).
  • pay() sobre una ACCEPTED con PDV ya asignado por documentos NO lo reasigna al PDV de quien paga.
  • La selfie y los cuatro de transferencia siguen sin bloquear pay() (test explícito, para que nadie los sume por error a REQUERIDOS_ANTES_DE_PAGAR en un cambio futuro).

2. Frontend

2.1 Componente nuevo: DocumentacionPrePago.vue

Igual estructura interna que DocumentacionPago.vue (mismo transactionDocumentsApi, mismo preview blob+Bearer, mismos guards de carrera de transactionId) pero:

  • Lista fija de 3 tipos (CARTA, IDENTIFICACION_ANVERSO, IDENTIFICACION_REVERSO), sin prop de deliveryType.
  • Sin badge Completa/Faltan N — en su lugar, emite cambio con el booleano de completitud para que el padre decida qué hacer (deshabilitar el botón), igual que ya hace DocumentacionPago.vue.
  • Mismo trato visual (círculos numerados, incluso siendo solo 3) para que no se sienta como una UI distinta dentro de la misma pantalla.

2.2 PagoView.vue

  • Nueva sección, visible cuando resultadoActual?.status === 'ACCEPTED', con <DocumentacionPrePago :transaction-id="..." @cambio="prePagoCompleto = $event" />.
  • puedePagar (hoy = puedeOperar, línea 46) pasa a puedeOperar && prePagoCompleto.
  • Texto junto al botón deshabilitado: "Faltan: {lista legible de los que falten}" — reusar DOCUMENT_TYPES para los rótulos, mismo criterio que ya usa DocumentacionPago.vue.
  • Manejar el 422 MISSING_REQUIRED_DOCUMENTS en handleConfirmPay() (aunque el botón esté deshabilitado en el caso normal, dos pestañas del mismo usuario o una carrera entre operadores puede llegar igual — mismo criterio que ya aplica al 409 de INVALID_STATUS_TRANSITION).

2.3 TransaccionDetalleView.vue

Mismo cambio que en PagoView.vue: la sección pre-pago antes del botón "Cambiar a PAID" cuando transaction.status === 'ACCEPTED', y canAct (línea 30-32) exige además la completitud pre-pago.

3. Documentación funcional a actualizar en el mismo cambio

  • 00-resumen.md §0.4 — R3 pasa a "El pago se bloquea solo por Carta + identificación (anverso y reverso); ningún otro dato o documento lo frena", con nota "modificado por ADR-008, 2026-09-12". R4 sin cambios (sigue sin ser un estado).
  • 01-flujo-del-pago.md §1.1 (diagrama) y §1.4 — reescribir para reflejar que estos tres pasan a ANTES del nodo "Confirmar el pago", y que la razón de R3 (no frenar después de entregar la plata) sigue vigente para todo lo demás.
  • 02-datos-y-documentos.md §2.2 (tabla de obligatoriedad) — nueva columna o nota "¿Bloquea el pago?" junto a "¿Obligatorio?", porque dejan de ser sinónimos: los 8 obligatorios (todo menos selfie) siguen siendo obligatorios para la completitud posterior, pero solo 3 de esos 8 bloquean el pago.
  • Nuevo §2.7-bis o ampliar §2.7 con la pantalla de DocumentacionPrePago.vue.
  • doc/qa/2026-09-11-e5-pago-documentos-plan-de-pruebas.md — agregar la sección de pre-pago (o un plan de pruebas nuevo específico de este ADR, a decidir al implementar).

4. Qué NO cambia

  • Los 9 tipos y su obligatoriedad post-pago (§2.2) tal cual están.
  • La selfie sigue sin afectar nada (R5).
  • datosPago() / DatosPagoForm.vue — cero cambios, siguen sin bloquear.
  • La papeleta — cero cambios.
  • El endpoint de descarga (download()) y su auditoría — cero cambios; ya funciona igual para transacciones ACCEPTED con PDV asignado que para PAID, una vez aplicado 1.3.

Documentación viva — se actualiza junto con el código, no es un anexo aparte.