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
// 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()
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.php — authorizeScope() 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():
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:
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:
$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):
$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 422MISSING_REQUIRED_DOCUMENTSsi falta CARTA/IDENTIFICACION_ANVERSO/IDENTIFICACION_REVERSO.pay()funciona igual que hoy si los tres están (no rompe ningún test existente depay).- Subir un documento a una
ACCEPTEDsin PDV asignado le asigna el PDV del uploader. - Un segundo usuario, de otro PDV, no puede subir a una
ACCEPTEDque ya tiene PDV asignado por otro operador (404). pay()sobre unaACCEPTEDcon 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 aREQUERIDOS_ANTES_DE_PAGARen 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 dedeliveryType. - Sin badge
Completa/Faltan N— en su lugar, emitecambiocon el booleano de completitud para que el padre decida qué hacer (deshabilitar el botón), igual que ya haceDocumentacionPago.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 apuedeOperar && prePagoCompleto.- Texto junto al botón deshabilitado: "Faltan: {lista legible de los que falten}" — reusar
DOCUMENT_TYPESpara los rótulos, mismo criterio que ya usaDocumentacionPago.vue. - Manejar el 422
MISSING_REQUIRED_DOCUMENTSenhandleConfirmPay()(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 deINVALID_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 transaccionesACCEPTEDcon PDV asignado que paraPAID, una vez aplicado 1.3.

