Skip to content

ADR-008: Carta e identificación bloquean el pago — el resto de los documentos, no

Date: 2026-09-12 Status: Accepted — implementado 2026-09-12 Supersedes in part: R3 de Pago con documentos §0.4

Context

R3 fijó, como la decisión más importante del paquete de Pago con documentos: "el pago nunca se bloquea por documentos faltantes". La razón que lo sostiene es física — cuando el operador llega al paso de subir escaneos, la plata ya salió del cajón; frenar el registro en ese punto no evita nada, convierte un trámite pendiente en un descuadre de caja.

Carlos pidió invertir el orden (2026-09-12): antes de poder confirmar el pago, el operador tiene que haber subido la Carta de Soterex y la identificación del beneficiario (anverso y reverso). Sin esos tres documentos, el botón "Confirmar pago" queda bloqueado.

Esto no contradice la razón original de R3 — la sostiene. R3 protege la operación de un frenazo después de que la plata salió del cajón. Carta + identificación son, en cambio, exactamente lo que el operador tiene que cotejar antes de entregar la plata (§1.1 del flujo del pago: "Coteja carta contra identificación y rostro"), y hoy ese cotejo ocurre fuera de la app, sin dejar rastro hasta que alguien sube el escaneo — a veces mucho después, o nunca. Exigir el escaneo antes de pagar no le agrega fricción a un paso que ya existe en el mostrador: lo hace verificable.

Los demás documentos siguen sin bloquear, y por una razón que no es solo de alcance sino física: la Papeleta firmada no puede existir antes del pago — es el comprobante que el cliente firma después de recibir la plata. Exigirla antes sería pedir algo que todavía no puede existir. La selfie sigue opcional (R5) y los cuatro documentos de transferencia siguen atados al medio de pago, que tampoco es obligatorio cargar antes de pagar.

El problema de fondo que esto expone

Los documentos son PII sensible (R7) y su acceso se acota siempre por PDV (TransactionDocumentController::authorizeScope, backend/app/Http/Controllers/TransactionDocumentController.php:129-147), sobre la premisa escrita en el propio código de que "los documentos existen solo sobre transacciones ya resueltas". Una transacción ACCEPTED no tiene station_id todavía — es bolsa compartida por país (doc/plans/2026-07-25-fix-pdv-asignacion-transacciones.md), y se le asigna recién al resolverla en pay()/cancel().

Si ahora hay que subir documentos antes de resolver la transacción, esa premisa deja de ser cierta, y hay que decidir de qué PDV son esos tres documentos mientras la transacción sigue siendo de todo el país.

Decision

1. El primer documento subido sobre una ACCEPTED le asigna el PDV

Se reutiliza la misma resolución que ya usa pay() (TransactionController::resolveStationForResolution(), backend/app/Http/Controllers/TransactionController.php:445-454): al primer POST de un documento sobre una transacción ACCEPTED sin station_id, se le asigna el PDV resuelto para ese usuario (su PDV activo si tiene uno configurado, o el único PDV de su alcance). Un usuario con más de un PDV asignado y sin PDV activo no puede ser el primero en subir — mismo criterio que ya aplica hoy para pagar.

Desde ese momento, esa transacción deja de ser bolsa compartida para efectos de documentos: los tres escaneos son del PDV que los subió, con el mismo acotamiento de siempre. La transacción en sí sigue siendo ACCEPTED y sigue apareciendo en el listado general sin filtrar por PDV — el PDV asignado por el upload no la saca de la bolsa compartida de transacciones, solo fija el dueño de los documentos que ya existen.

Consecuencia que hay que cerrar en el mismo cambio: si otro operador (de otro PDV) termina pagando esa misma transacción, pay() hoy pisa station_id incondicionalmente ($locked->station_id = $resolvedStationId, línea 250). Con documentos ya asignados a un PDV distinto, ese pisado dejaría los tres escaneos huérfanos — subidos por un PDV, visibles después solo para otro. pay() pasa a no reasignar station_id si la transacción ya lo tiene ($locked->station_id ??= $resolvedStationId): quien sube el primer documento se queda como dueño del PDV aunque otro operador resuelva el pago. Es una ventana angosta (competencia real por la misma transacción, entre que se suben los documentos y se paga) pero es plata y PII a la vez; no se deja sin resolver.

2. El endpoint de documentos deja de asumir "transacción ya resuelta"

TransactionDocumentController::authorizeScope() acota siempre por PDV bajo la premisa de que todo documento cuelga de una transacción ya resuelta. Pasa a distinguir:

  • Transacción ya resuelta (PAID/CANCELLED): sin cambios — se acota por station_id como hoy.
  • Transacción ACCEPTED sin station_id todavía: cualquier usuario con permiso de escritura sobre el país puede subir (mismo criterio de bolsa compartida que ya usa TransactionController::authorizeScope para ACCEPTED — no es un mecanismo nuevo, es extender uno que ya existe a este controller).
  • Transacción ACCEPTED con station_id ya asignado (por un upload anterior): se acota por ese PDV — es exactamente la misma regla que rige para PAID/CANCELLED, aplicada un paso antes.

3. pay() exige los tres documentos antes de resolver

Dentro de la transacción de base de datos de pay(), después del re-chequeo de estado ($locked->status !== 'ACCEPTED') y antes de tocar caja o prefondeo: si falta CARTA, IDENTIFICACION_ANVERSO o IDENTIFICACION_REVERSO, se corta con 422 y un código nuevo, MISSING_REQUIRED_DOCUMENTS, con la lista de los que faltan — mismo patrón que INSUFFICIENT_PREFUNDING (422, no 409: no es un conflicto de concurrencia, es una precondición que no se cumple).

Se valida dentro de la transacción con lock, no antes: dos operadores pueden estar completando documentos a la vez sobre la misma ACCEPTED (es bolsa compartida), y el que efectivamente confirma el pago tiene que ver el estado real en ese instante, no uno leído al abrir la pantalla.

TransactionDocument gana una constante nueva, REQUERIDOS_ANTES_DE_PAGAR = ['CARTA', 'IDENTIFICACION_ANVERSO', 'IDENTIFICACION_REVERSO'] — subconjunto de REQUERIDOS_SIEMPRE que excluye PAPELETA_FIRMADA (que, como se explicó arriba, no puede existir todavía). Transaction gana faltanDocumentosPrePago(): array, misma forma que faltanDocumentos() pero contra ese subconjunto.

4. El frontend pide los tres documentos en la pantalla Pago, antes del botón de confirmar

PagoView.vue muestra una sección de documentos antes del bloque de acciones (hoy el botón "Confirmar pago" es lo primero que aparece tras encontrar una ACCEPTED), con los tres tipos bloqueantes únicamente — no los nueve. "Confirmar pago" queda deshabilitado con un texto tipo "Faltan: Carta de Soterex, Identificación (reverso)" hasta que estén los tres.

No se reutiliza DocumentacionPago.vue tal cual. Ese componente already-existente sirve una lista de nueve tipos, no bloqueante, para una transacción ya pagada — mezclar ahí un modo "tres tipos, bloqueante, pre-pago" le agrega una rama condicional a un componente cuyo comentario de cabecera dice explícitamente "nunca hay un paso bloqueado (R3)", que dejaría de ser cierto para uno de sus modos. Se extrae un componente nuevo y más chico, DocumentacionPrePago.vue, que comparte el servicio (transactionDocumentsApi) y la lógica de preview (blob + Bearer) pero no el componente visual. Después de pagar, la transacción sigue mostrando el stepper de nueve existente —sin cambios— y ya aparece con esos tres marcados, porque son los mismos documentos.

Alternatives Considered

  • Reutilizar DocumentacionPago.vue con un prop de modo (bloqueante: boolean / tiposRequeridos: DocumentType[]). Menos código nuevo, pero convierte al único componente cuyo contrato hoy es "nunca bloquea" en uno que a veces sí — cualquiera que lo toque después tiene que leer las dos ramas para saber si un cambio es seguro. El componente nuevo es chico (tres tipos, sin badge de completitud de nueve, sin selfie) y no repite la parte sensible (preview) porque la comparte vía el mismo service.
  • No asignar PDV al primer upload; dejar los documentos pre-pago sin acotar por PDV mientras la transacción sea ACCEPTED. Es la alternativa que se descartó explícitamente al decidir esto (opción presentada y no elegida): más simple —ni siquiera hace falta tocar station_id— pero dos identificaciones de personas distintas quedarían visibles para cualquier operador del país mientras nadie pague, debilitando exactamente el pilar que R7 protege (acotamiento por PDV de PII sensible). Se prefiere la asignación temprana aunque cueste la ventana de reasignación del punto 1.
  • Validar los documentos completos (los nueve) antes de pagar, no solo los tres de identificación. Es físicamente imposible para la Papeleta firmada (no existe hasta después del pago) y no tiene sentido para los cuatro de transferencia (el medio de pago mismo no es obligatorio antes de pagar). Descartado por la misma razón que motivó R3 en primer lugar: no confundir "verificar antes de entregar la plata" con "frenar el registro después de entregarla".
  • Agregar el bloqueo como una validación de UI únicamente (deshabilitar el botón en el frontend), sin tocar pay() en el backend. Descartado: cualquier cliente que le pegue directo a la API (o un bug futuro en el frontend) confirmaría pagos sin identificación verificada — la regla de negocio tiene que vivir donde se puede hacer cumplir, no solo donde se muestra.

Consequences

  • Positive: la verificación de identidad que hoy ocurre en el mostrador, sin dejar rastro hasta que (si acaso) alguien sube el escaneo, pasa a ser una precondición real y auditada del pago. Cierra el hueco que R9/R7 dejaban abierto: hoy una transacción puede estar PAID hace semanas sin que nadie haya verificado nunca la identidad de quien cobró.
  • Negative: el flujo de pago se vuelve más largo en el caso más común (cliente presente, con carta e identificación en la mano) — tres subidas de archivo antes de poder confirmar, donde antes había un solo click. Es el costo directo y aceptado de esta decisión.
  • Negative: introduce la primera excepción a R3 desde que se escribió — el próximo lector de R3/R4 tiene que leer también este ADR para entender que "el pago nunca se bloquea" ya no es literalmente cierto para estos tres tipos. Se corrige actualizando R3/R4 y §1.1/§1.4/§1.5/§2.2 del Análisis Funcional de Pago con documentos en el mismo cambio que implemente esto, no después.
  • Negative: pay() pasa a depender de transaction_documents, una tabla de otro dominio (hasta ahora pay() solo tocaba transactions, caja y prefondeo). Es el mismo tipo de acoplamiento que ya aceptó R4 al derivar documentacion_completa de esa tabla — no es un patrón nuevo, es extender uno ya aceptado a un momento más temprano del flujo.
  • Neutral: no cambia nada de la Papeleta, la selfie, los datos de datosPago(), ni los cuatro documentos de transferencia — siguen exactamente como R3/R4/R5 los dejaron.

Implementación (2026-09-12)

Diseño llevado a código tal cual, con dos precisiones que no estaban en el diseño técnico original:

  • La lógica compartida entre DocumentacionPago.vue (post-pago) y el nuevo DocumentacionPrePago.vue —el preview blob+Bearer y los guards contra respuestas fuera de orden cuando el operador cambia de transacción sin recargar— se extrajo a un composable (frontend/src/composables/useTransactionDocuments.ts) en vez de duplicarse. Ninguno de los dos componentes comparte template ni contrato de bloqueo, solo esa mecánica.
  • Ventana de carrera aceptada, no cerrada con lock: el primer upload sobre una ACCEPTED sin PDV lee station_id === null y lo asigna sin lockForUpdate() (a diferencia de pay(), que sí lockea). Dos operadores de PDVs distintos subiendo el primer documento de la misma transacción casi al mismo tiempo podrían, en teoría, asignarle el PDV del que gane la escritura en vez de un "primero en llegar" estricto. Se acepta sin lock porque no hay plata en juego en ese paso (a diferencia de pay()/cancel(), que si lockean) — el peor caso es una ambigüedad de PDV sobre documentos, no un descuadre de caja.

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