Skip to content

ADR - Registro de Decisiones Arquitectonicas

Decisiones arquitectonicas del Portal de Clientes, documentadas en abril 2026.

Estado: Todas las decisiones son finales.


ADR-001: Repositorio

Contexto: El frontend del portal necesita un repositorio. Las opciones son agregarlo al repo existente bautista-app (ERP administrativo) o crear un repo independiente.

Opciones consideradas:

  1. Directorio dentro de bautista-app/ -- menor overhead de repos, pero acopla portal con ERP admin
  2. Repo independiente -- separacion total, deploy independiente

Decision: Frontend en repo independiente portal-usuarios, agregado como submodulo git del monorepo.

Razon:

  • El portal tiene un ciclo de vida distinto al ERP admin
  • Diferentes requisitos de deploy (Docker por tenant vs deploy unico)
  • Evita acoplar codigo de clientes con codigo de administracion
  • El submodulo permite referenciar desde el monorepo sin mezclar historiales

ADR-002: Autenticacion

Contexto: Los clientes del portal necesitan autenticarse. El sistema anterior proponia identificacion simple por DNI/CUIT (sin password). Se evaluo si eso es suficiente o si se necesita autenticacion real.

Opciones consideradas:

  1. Identificacion por DNI/CUIT (sin password) -- simple pero inseguro, cualquiera con un DNI accede
  2. JWT con password (bcrypt) -- seguridad real, misma mecanica que Admin UI
  3. OAuth/SSO externo -- complejidad innecesaria para el alcance

Decision: JWT validado en backend, mismo mecanismo que Admin UI. Password con bcrypt hash.

Detalles:

  • JWT payload: { portal_user_id, tenant_id, sucursal_id } -- solo IDs numericos, sin nombres de DB/schema
  • El backend resuelve: tenant_id -> DB name via ini.sistema; sucursal_id -> schema
  • Auto-registro: DNI/CUIT debe coincidir con un ordcon existente. Si no hay match, el registro falla. Solo clientes reales pueden crear cuentas
  • Reset de password via codigo enviado por email (seguridad moderada dado que el portal es mayormente lectura de deudas + pagos)
  • Tabla de credenciales: portal_users

Razon:

  • La identificacion sin password es inaceptable: expone datos financieros (deudas) a cualquiera que conozca un DNI
  • JWT es el mecanismo ya probado en el Admin UI, no hay motivo para reinventar
  • El auto-registro con match de ordcon evita cuentas falsas sin requerir flujos de aprobacion manual

ADR-003: Arquitectura Backend

Contexto: El backend necesita endpoints para el portal. El proyecto tiene dos patrones: legacy 5-capas (App\Controller\*) y DDD moderno (Modules/*).

Opciones consideradas:

  1. Legacy App\Controller\Portal\* -- rapido de implementar pero inconsistente con la direccion del proyecto
  2. DDD moderno Modules/Portal/ -- consistente con CRM y Membresia, mejor organizacion

Decision: Modulo DDD moderno en Modules/Portal/ con sub-modulos por bounded context.

Sub-modulos:

  • Auth/ -- login, registro, reset password, validacion JWT
  • Account/ -- consulta de cuenta corriente, deudas pendientes, datos del cliente
  • Payment/ -- inicio de pago, procesamiento de webhooks, creacion automatica de recibos
  • Cupon/ -- generacion de cupones de pago (wrapper sobre servicios existentes)

Razon:

  • Consistencia con los modulos modernos del proyecto (CRM, Membresia)
  • Bounded contexts claros que facilitan el testing y mantenimiento
  • El portal es funcionalidad nueva, no tiene sentido usar el patron legacy

ADR-004: Modelo de Deployment

Contexto: Se necesita definir como se despliega el portal para multiples tenants. La arquitectura anterior proponia una app unica generica con resolucion por dominio.

Opciones consideradas:

  1. App generica multi-tenant con resolucion por dominio -- una instancia, tabla tenant_domains, wildcard DNS
  2. Docker por tenant (frontend-only), backend compartido -- una instancia Docker por tenant con .env, backend unico
  3. Full Docker por tenant (frontend + backend) -- aislamiento total pero duplicacion masiva

Decision: Frontend-only Docker por tenant, backend compartido (bautista-backend).

Detalles:

  • Cada instancia Docker del frontend tiene .env con: BACKEND_URL, TENANT_ID, SUCURSAL_ID, config de branding (APP_NAME, LOGO_URL, PRIMARY_COLOR, etc.)
  • El backend es el mismo bautista-backend para todos los tenants
  • NO hay tabla tenant_domains
  • NO hay resolucion por dominio: la URL del frontend es irrelevante
  • La configuracion de conexion ocurre en deploy, no en runtime

Razon:

  • Simplicidad: agregar un tenant es crear un Docker con .env, no configurar DNS + SSL + registros en BD
  • Aislamiento frontend: cada tenant tiene su propio proceso, no comparte estado
  • El backend ya es multi-tenant (ConnectionManager, schemas PostgreSQL), no necesita duplicacion
  • Branding resuelto en build time via variables de entorno, sin logica runtime compleja
  • Eliminacion de complejidad DNS: no se necesitan wildcard certificates, CNAME records ni configuracion de dominios

ADR-005: Base de Datos

Contexto: El portal necesita tablas nuevas. Se debe decidir donde ubicarlas y cuales crear.

Opciones consideradas:

  1. Tablas en schema public de cada DB -- simple pero no respeta la estructura multi-schema del sistema
  2. Tablas al mismo nivel de schema que ordcon -- consistente con la arquitectura existente, dinamico por tenant
  3. Tablas en DB separada para el portal -- aislamiento total pero complejidad de joins

Decision: Tablas al mismo nivel de schema que ordcon, dinamico segun configuracion del tenant en ini.sistema.

Tablas:

  • portal_users -- credenciales de acceso al portal (hash bcrypt, email, vinculacion con ordcon)
  • portal_payments -- registro de pagos online (estado, referencia gateway, facturas asociadas)

Tablas que NO se crean:

  • tenant_domains -- no existe resolucion por dominio (ADR-004)
  • portal_cupones -- los cupones reutilizan servicios existentes (ADR-007)

Razon:

  • Ubicar las tablas al mismo nivel que ordcon permite foreign keys directas
  • Es consistente con la arquitectura multi-schema existente del sistema
  • Cada tenant puede tener ordcon en un nivel diferente; portal_users sigue la misma regla

ADR-006: Alcance

Contexto: La arquitectura anterior proponia un approach por fases (MVP de 4 semanas + Fase 2 de 4 semanas). Se evaluo si esto tiene sentido para el portal.

Opciones consideradas:

  1. MVP + fases incrementales -- entrega temprana pero feature incompleta (portal sin pagos no tiene mucho valor)
  2. Feature completa sin fases -- mayor esfuerzo inicial pero entrega con valor real desde el dia uno

Decision: Alcance completo, sin fases MVP.

Razon:

  • Un portal que solo muestra deudas sin permitir pagos tiene valor limitado
  • Los cupones son parte integral del flujo de cobro
  • Implementar en fases agrega overhead de planificacion sin beneficio real
  • La arquitectura (Docker por tenant, DDD module, JWT) es la misma para cualquier alcance

ADR-007: Cupones

Contexto: El portal necesita generar cupones de pago. El sistema ya tiene servicios de cupones (CuponPagoService, CuponValidacionService).

Opciones consideradas:

  1. Crear nuevos servicios de cupones para el portal -- duplicacion innecesaria
  2. Reutilizar servicios existentes -- consistencia, sin duplicacion
  3. Tabla portal_cupones dedicada -- overhead sin beneficio

Decision: Reutilizar CuponPagoService y CuponValidacionService existentes. NO se crea tabla portal_cupones.

Razon:

  • Los servicios existentes ya implementan la logica completa de cupones
  • El sub-modulo Cupon/ en Modules/Portal/ actua como wrapper/adapter
  • No tiene sentido duplicar logica que ya esta probada y en produccion
  • Los cupones generados son los mismos que los del sistema admin (misma validacion, mismo formato)

ADR-008: Branding

Contexto: Cada tenant necesita mostrar su propia identidad visual en el portal (logo, colores, nombre).

Opciones consideradas:

  1. Branding en tabla tenant_domains -- requiere tabla que no existe (ADR-004)
  2. Branding en .env del Docker + data_config del tenant -- simple, sin tablas extra
  3. Branding solo en .env -- limitado, no se puede cambiar sin re-deploy

Decision: Branding via .env del Docker instance + data_config del tenant.

Detalles:

  • Variables de .env: APP_NAME, LOGO_URL, PRIMARY_COLOR, SECONDARY_COLOR
  • data_config del tenant en la DB puede complementar o extender la configuracion base
  • El frontend usa .env como valores por defecto (disponibles sin request al backend)
  • Si el backend provee config adicional via API, el frontend puede combinarla

Razon:

  • .env da branding inmediato sin depender del backend (primera carga rapida)
  • data_config permite cambios dinamicos sin re-deploy del contenedor
  • No se necesita tabla nueva: data_config ya existe en la estructura del sistema

ADR-009: Algoritmo JWT

Contexto: El Admin UI usa RS256 para firmar JWTs. Se evaluo si el portal debe usar el mismo algoritmo o uno diferente.

Opciones consideradas:

  1. RS256 (mismo que Admin UI) -- reutiliza infraestructura de claves, pero un token del portal podria confundirse con uno del admin si no se validan claims correctamente
  2. HS256 con secret separado (PORTAL_JWT_SECRET) -- separacion natural por algoritmo y secreto

Decision: HS256 con PORTAL_JWT_SECRET independiente en el .env del backend.

Razon:

  • Separacion natural: un JWT del portal NUNCA puede funcionar en endpoints del admin (diferente algoritmo + diferente secreto)
  • HS256 es mas simple de operar (un solo secreto vs par de claves publica/privada)
  • No se necesita la capacidad de verificacion por terceros que RS256 ofrece (el unico verificador es el propio backend)

ADR-010: Refresh Token

Contexto: El token JWT tiene expiracion de 1 hora. Se debe decidir si el usuario debe re-loguearse cada vez que expire o si se implementa un mecanismo de renovacion.

Opciones consideradas:

  1. Sin refresh token -- simple pero mala UX (re-login cada hora)
  2. Refresh token como JWT -- mas complejo, requiere blacklist
  3. Refresh token como UUID almacenado en portal_users -- simple, revocable, una sesion activa por usuario

Decision: Refresh token UUID almacenado en portal_users. Dos columnas nuevas: refresh_token (VARCHAR, UNIQUE, nullable) y refresh_token_expires (TIMESTAMP, nullable).

Detalles:

  • En login: generar UUID, almacenar en portal_users, retornar al cliente junto con el access token
  • En refresh: validar UUID + expiracion, generar nuevo access JWT + nuevo refresh UUID
  • Solo UNA sesion activa por usuario: un nuevo login sobreescribe el refresh token anterior
  • Logout revoca el refresh token (SET NULL)

Razon:

  • Mejor UX: el usuario no necesita re-loguearse cada hora
  • UUID en base de datos es revocable inmediatamente (a diferencia de un JWT que vive hasta expirar)
  • Una sesion por usuario simplifica la gestion y evita acumulacion de tokens

ADR-011: Resolucion de Tenant en Webhooks

Contexto: Los webhooks de gateways de pago llegan al backend sin contexto de tenant (no hay JWT). Se debe resolver que tenant corresponde al pago notificado.

Opciones consideradas:

  1. Query params en la URL del webhook (?tenant_id=1&sucursal_id=1) -- simple pero expone informacion, facil de manipular
  2. Resolucion por dominio de origen -- complejo, no todos los gateways envian dominio
  3. Metadata en portal_payments -- al crear el pago se almacena tenant_id + sucursal_id en la fila; al recibir webhook se busca por external_id

Decision: Metadata en portal_payments. Al crear el pago (POST /portal/pagos/iniciar), almacenar tenant_id y sucursal_id en la fila de portal_payments. Cuando el webhook llega con external_id, buscar en portal_payments, obtener el contexto de tenant de la fila, resolver DB/schema, y procesar.

Razon:

  • No expone informacion de tenant en URLs
  • No depende de configuracion DNS ni headers del gateway
  • El dato ya esta disponible al momento de crear el pago (viene del JWT del usuario)
  • Patron simple: lookup por external_id -> contexto completo

ADR-012: CORS

Contexto: El portal tiene multiples instancias Docker por tenant, cada una con su propio dominio. Se debe configurar CORS en el backend.

Opciones consideradas:

  1. CORS por tenant -- cada deploy tiene su dominio en .env, el backend lo lee y configura Access-Control-Allow-Origin especifico
  2. Wildcard * -- acepta cualquier origen

Decision: Wildcard * para endpoints del portal.

Razon:

  • Los endpoints del portal estan protegidos por JWT; el CORS no es la capa de seguridad
  • Wildcard simplifica la configuracion: no hay que mantener dominios por tenant en el backend
  • Agregar un nuevo tenant no requiere tocar configuracion de CORS

ADR-013: Email

Contexto: El portal necesita enviar emails (codigos de recuperacion de password). Se debe decidir si se configura un servicio de email independiente o se reutiliza el existente.

Opciones consideradas:

  1. Servicio de email independiente para el portal -- aislamiento pero duplicacion de configuracion
  2. Reutilizar configuracion de email existente del sistema Bautista -- sin overhead adicional

Decision: Reutilizar la configuracion de email existente del sistema (SMTP/servicio ya configurado en Bautista).

Razon:

  • El sistema ya envia emails en otros modulos
  • No hay motivo para duplicar configuracion SMTP
  • Menos variables de entorno, menos puntos de falla

ADR-014: Bloqueo por Intentos Fallidos

Contexto: Se debe definir cuantos intentos fallidos de login se permiten antes de bloquear la cuenta temporalmente.

Opciones consideradas:

  1. 3 intentos -- agresivo, puede frustrar usuarios legitimos que confunden password
  2. 5 intentos -- balance entre seguridad y usabilidad
  3. 10 intentos -- demasiado permisivo

Decision: 5 intentos fallidos consecutivos resultan en bloqueo de 15 minutos.

Razon:

  • 5 intentos es suficiente para que un usuario con typos no se bloquee facilmente
  • 15 minutos es suficiente para frenar ataques de fuerza bruta sin ser excesivamente punitivo
  • Login exitoso resetea el contador a 0

ADR-015: Politica de Password

Contexto: Se debe definir los requisitos minimos de complejidad para passwords del portal.

Opciones consideradas:

  1. 8 caracteres + 1 mayuscula + 1 numero + 1 caracter especial -- demasiado estricto para clientes finales
  2. 8 caracteres + 1 numero -- balance entre seguridad y usabilidad
  3. 6 caracteres sin restricciones -- demasiado debil

Decision: Minimo 8 caracteres y al menos 1 numero. Sin requerimiento de mayusculas ni caracteres especiales.

Razon:

  • Los usuarios del portal son clientes finales (no personal tecnico); politicas muy estrictas generan friccion y consultas de soporte
  • 8 caracteres + 1 numero es un minimo razonable que previene passwords triviales
  • El portal es mayormente consulta de deudas y pagos, no es un sistema critico de administracion

ADR-016: Arquitectura de Gateway de Pago

Contexto: El sistema debe soportar multiples gateways de pago. El primer cliente usa Pago TIC (PayPerTIC), pero otros tenants podrian usar MercadoPago u otros proveedores. Se debe decidir como estructurar la integracion para que agregar nuevos gateways no requiera cambios en el service core.

Opciones consideradas:

  1. Gateway unico hardcodeado -- rapido pero limita a un solo proveedor, cambiar requiere reescribir el service
  2. Patron Adapter con Factory por tenant -- cada gateway implementa una interfaz estandar, el service es gateway-agnostic, la factory resuelve por configuracion del tenant

Decision: Patron Adapter con Factory, seleccion de gateway configurable por tenant.

Detalles:

  • Interfaz estandar: PaymentGatewayInterface con 6 metodos: createPayment, validateWebhook, processWebhook, getPaymentStatus, cancelPayment, refundPayment
  • DTOs estandar: PaymentRequest, PaymentResponse, WebhookResult -- normalizan las diferencias entre APIs de distintos gateways
  • PaymentGatewayFactory: Lee ini.sistema.payment_gateway del tenant e instancia el adapter correspondiente. Agregar un nuevo gateway = crear un adapter + registrar en la factory
  • Configuracion por tenant: Campo payment_gateway (nombre del adapter: paypertic, mercadopago) y payment_gateway_config (JSON con credenciales) en ini.sistema
  • Campo gateway en portal_payments: Almacena que adapter se uso para crear el pago. Necesario para: (a) webhook routing -- saber que adapter usar para validar/procesar el webhook, (b) reportes -- filtrar pagos por gateway
  • Webhook gateway-agnostic: Un solo endpoint (POST /portal/pagos/webhook) para todos los gateways. La resolucion del adapter se hace por lookup: external_id del payload -> portal_payments.gateway -> factory -> adapter
  • Field mapping: Cada adapter mapea campos internamente. Ejemplo: PagoTIC retorna form_url como redirect_url del DTO; MercadoPago retornaria init_point

Razon:

  • El patron Adapter permite agregar nuevos gateways sin modificar PaymentGatewayService
  • La configuracion por tenant da flexibilidad: tenant A usa PagoTIC, tenant B usa MercadoPago
  • Los DTOs estandar aseguran que el service no necesita logica condicional por gateway
  • El campo gateway en portal_payments elimina la necesidad de rutas de webhook por gateway

ADR-017: Frontend UI Library -- shadcn/ui + Radix + Tailwind

Contexto: El portal necesita una libreria de componentes UI. El requisito principal es que soporte branding customizable por tenant: cada instancia Docker muestra colores, fuentes y logotipos diferentes segun la configuracion del tenant.

Opciones consideradas:

  1. shadcn/ui + Radix UI + Tailwind CSS -- componentes headless copiados al proyecto, estilizados via CSS variables de Tailwind. Theming completo via variables CSS
  2. MUI (Material UI) -- componentes pre-estilizados con Material Design, theming via createTheme(). Override de estilos requiere sx prop o styled(). Tema de Material visible por defecto
  3. Tailwind CSS puro (sin libreria de componentes) -- control total, pero hay que construir cada componente desde cero. Accesibilidad manual (ARIA, focus, keyboard)

Decision: shadcn/ui + Radix UI + Tailwind CSS.

Razon:

  • Branding por tenant: shadcn/ui se estiliza via CSS variables (--primary, --secondary, etc.). Cambiar el branding es cambiar variables CSS, que es exactamente lo que el BrandingContext hace al leer import.meta.env. No hay que pelear contra un design system pre-definido como Material Design
  • Componentes headless: Radix UI maneja accesibilidad (ARIA, focus management, keyboard navigation) sin imponer estilos. Los componentes son accesibles por defecto
  • Copy-paste model: Los componentes se copian al proyecto (src/components/ui/), no se instalan como dependencia. Esto permite modificar cualquier componente sin restricciones de la libreria
  • Tailwind integration: shadcn/ui esta construido sobre Tailwind, que ya es la herramienta de styling del proyecto. No hay conflicto de sistemas de estilos
  • Bundle size: Solo se incluyen los componentes que se usan (copy-paste, no package completo)

Descartadas:

  • MUI: el override de Material Design para lograr branding custom es mas trabajo que construir sobre headless components. Ademas, el bundle es significativamente mayor
  • Tailwind puro: construir accesibilidad manualmente para todos los componentes (Dialog, Select, Dropdown) es costoso y propenso a errores

ADR-018: State Management -- TanStack Query + React Context

Contexto: El portal necesita gestion de estado. La mayor parte del estado es server state: deudas, pagos, cupones, resumen de cuenta. El unico client state significativo es la autenticacion (JWT, datos del usuario) y el branding (configuracion del tenant).

Opciones consideradas:

  1. TanStack Query + React Context -- TQ para server state (cache, refetch, mutations, polling), Context para auth y branding
  2. Zustand + TanStack Query -- Zustand para client state, TQ para server state. Zustand agrega una dependencia y abstraccion que Context ya resuelve para 2 contextos simples
  3. React Context solo -- Factible pero requiere implementar cache, invalidacion y refetch manual para server state. Reinventar la rueda

Decision: TanStack Query para server state + React Context para auth y branding.

Razon:

  • 90% server state: El portal es mayormente consulta de datos del backend. TanStack Query gestiona cache, refetch automatico, mutations con invalidacion, y polling (refetchInterval). No tiene sentido implementar esto manualmente
  • 10% client state: Solo AuthContext (JWT, usuario logueado) y BrandingContext (colores/nombre del tenant). Son dos contextos simples y estables. No justifican una libreria de state management como Zustand o Redux
  • Polling gratis: TanStack Query soporta refetchInterval nativamente, que es exactamente lo que se necesita para actualizar el estado de pago en tiempo real (ADR-022)
  • Separation of concerns: Server state (TQ) y client state (Context) estan claramente separados. No hay un store monolitico que mezcle datos del servidor con estado local

Descartadas:

  • Zustand: agrega complejidad y una dependencia sin beneficio real. Para 2 contextos (auth, branding), React Context es suficiente
  • Context solo: viable pero requiere implementar caching, deduplication de requests, invalidacion, y polling. TanStack Query resuelve todo esto out of the box

ADR-019: Router -- TanStack Router

Contexto: El portal necesita un router SPA con soporte para search params tipados (filtros en cupones, paginacion en historial, parametros de retorno de gateway de pago).

Opciones consideradas:

  1. React Router v7 -- router mas popular, API conocida. Search params como strings sin tipado
  2. TanStack Router -- type-safe routing con validacion de search params via Zod, inferencia de tipos para parametros de ruta

Decision: TanStack Router.

Razon:

  • Type safety en search params: Las paginas del portal usan search params para: filtros de cupones (?estado=pending), paginacion (?page=2), y parametros de retorno del gateway (?payment_id=xxx&external_reference=yyy). TanStack Router valida estos params con Zod schemas, eliminando parsing manual y errores de tipo
  • Validacion integrada: validateSearch en la definicion de la ruta asegura que los search params tienen el tipo correcto antes de que el componente se renderize
  • Consistencia con TanStack Query: El mismo ecosistema TanStack. Loader pattern de TanStack Router se integra naturalmente con query prefetching
  • beforeLoad para guards: Autenticacion con beforeLoad que lanza redirect() si el usuario no tiene JWT. Mas limpio que wrapper components o higher-order components

Descartadas:

  • React Router v7: funcional pero los search params son strings sin tipado. Para un portal con filtros y paginacion en URLs, la falta de validacion automatica genera boilerplate de parsing y errores potenciales

ADR-020: Build per Tenant

Contexto: Las variables VITE_* son build-time only por diseno de Vite. Se debe decidir como configurar cada tenant: compilar una imagen Docker por tenant con sus variables integradas, o inyectar configuracion en runtime.

Opciones consideradas:

  1. Build per tenant -- cada imagen Docker se compila con --build-arg que setea las variables VITE_*. El build de Vite reemplaza import.meta.env.VITE_* por los valores literales en el bundle
  2. Runtime config injection -- un unico build generico. Al iniciar el contenedor, un script reemplaza placeholders en el JS bundle o inyecta un window.__CONFIG__ via nginx. El frontend lee config de window.__CONFIG__ en vez de import.meta.env

Decision: Build per tenant. Cada imagen Docker se compila con las variables VITE_* del tenant.

Razon:

  • Simplicidad: import.meta.env funciona tal como Vite lo diseno. No hay hacks de runtime, no hay placeholders, no hay scripts de inyeccion al iniciar el contenedor
  • Inmutabilidad: Cada imagen Docker es inmutable y reproducible. El mismo tag siempre produce el mismo comportamiento. No depende de variables de entorno al correr el contenedor
  • Vite lo espera asi: Las variables VITE_* son build-time by design. Ir contra esto agrega complejidad sin beneficio real
  • Costo bajo: El build tarda < 1 minuto por tenant con cache de Docker layers. La capa de npm install se cachea, solo se re-ejecuta el vite build con las nuevas variables
  • Debugging simplificado: El bundle compilado contiene los valores reales. No hay indirecciones ni config files externos que puedan estar mal

Descartadas:

  • Runtime injection: agrega complejidad (script de inyeccion, window.__CONFIG__, cambio de patron en todo el frontend) para evitar un build que tarda < 1 minuto. El trade-off no justifica la complejidad

ADR-021: Cupon PDF via Backend Proxy

Contexto: El servicio de informes (repo informes/) genera PDFs y corre en el puerto 9999 del servidor. Este servicio no esta expuesto al internet. El portal necesita permitir a los clientes descargar PDFs de cupones de pago.

Opciones consideradas:

  1. Generacion en frontend (jsPDF) -- generar el PDF directamente en el navegador del cliente. No requiere backend
  2. Backend proxy (stream) -- el backend expone un endpoint que recibe el request del frontend, llama internamente al servicio de informes en puerto 9999, y retorna el PDF como stream al cliente
  3. URL firmada (pre-signed URL) -- el backend genera una URL temporal con token que el frontend usa para descargar directamente del servicio de informes

Decision: Backend proxy stream. Nuevo endpoint GET /portal/cupones/{id}/pdf.

Flujo:

  1. Frontend llama a GET /portal/cupones/{id}/pdf con JWT en header
  2. Backend valida JWT, resuelve tenant, verifica que el cupon pertenece al usuario
  3. Backend llama internamente a http://localhost:9999/cupon/{id} (servicio de informes)
  4. Backend retorna el PDF como stream con Content-Type: application/pdf
  5. Frontend recibe el blob y triggerea la descarga

Razon:

  • Seguridad: El servicio de informes (puerto 9999) nunca se expone al internet. El backend actua como gateway con autenticacion y autorizacion
  • Autorizacion: El backend verifica que el cupon pertenece al usuario autenticado antes de solicitar el PDF. Un cliente no puede descargar cupones de otro cliente
  • Consistencia: Misma autenticacion (JWT) y mismos headers de tenant que cualquier otro endpoint del portal
  • El PDF ya existe: Los servicios del repo informes/ ya generan el PDF con el formato correcto (codigo de barras ITF, datos del cupon). Generarlo en frontend con jsPDF seria duplicar logica y probablemente con menor calidad

Descartadas:

  • jsPDF: duplica la logica de generacion que ya existe en el backend. Los PDFs generados en el navegador tienen limitaciones de formato y fuentes
  • URL firmada: requiere exponer el servicio de informes al internet (aunque sea con token temporal) y agrega complejidad de generacion/validacion de tokens

ADR-022: Polling para Actualizaciones en Tiempo Real

Contexto: Despues de que un cliente completa un pago en el gateway externo y es redirigido de vuelta al portal (/pagar/exito o /pagar/pendiente), el frontend necesita mostrar el estado actualizado del pago. El webhook del gateway puede tardar segundos o minutos en llegar al backend y actualizar portal_payments.

Opciones consideradas:

  1. Refresh manual -- el usuario recarga la pagina para ver el estado actualizado. Simple pero mala UX
  2. Polling automatico -- el frontend consulta el estado del pago cada N segundos via TanStack Query refetchInterval. Se detiene cuando el pago alcanza un estado final
  3. WebSockets -- conexion persistente bidireccional. El backend notifica al frontend cuando el webhook actualiza el pago
  4. Server-Sent Events (SSE) -- conexion unidireccional del servidor al cliente. Similar a WebSockets pero mas simple

Decision: Polling automatico via TanStack Query refetchInterval.

Detalles:

  • En paginas de resultado de pago (/pagar/exito, /pagar/pendiente): refetchInterval: 5000 (cada 5 segundos)
  • En pagina de deudas (si hay pagos recientes pendientes de acreditacion): refetchInterval: 10000 (cada 10 segundos)
  • refetchIntervalInBackground: false -- no pollea cuando la ventana/tab no tiene foco
  • El polling se detiene automaticamente cuando el pago alcanza un estado final (approved, rejected, cancelled): refetchInterval retorna false

Razon:

  • Sin infraestructura extra: Polling usa los mismos endpoints REST existentes. No requiere servidor WebSocket, Redis pub/sub, ni configuracion de SSE
  • TanStack Query lo soporta nativamente: refetchInterval es un parametro estandar del hook useQuery. No hay logica custom de timers o intervals
  • Carga aceptable: Un GET cada 5 segundos por usuario que esta viendo el resultado de su pago es negligible. El volumen de usuarios concurrentes del portal no justifica la complejidad de WebSockets
  • Auto-stop: El polling se detiene cuando no se necesita (estado final alcanzado, ventana fuera de foco). No genera carga innecesaria
  • Simplicidad de implementacion: Son 3 lineas de configuracion en el hook de TanStack Query. WebSockets o SSE requieren setup en backend, manejo de conexiones, reconexion automatica, y testing adicional

Descartadas:

  • WebSockets: infraestructura compleja (servidor WS, manejo de conexiones, reconexion) para un caso de uso simple. El portal no tiene necesidades de comunicacion bidireccional en tiempo real
  • SSE: mas simple que WebSockets pero aun requiere endpoint especial en el backend, manejo de reconexion, y configuracion de nginx para long-polling. No justificado para el volumen de uso esperado
  • Refresh manual: mala UX. El usuario no sabe cuando recargar y puede pensar que el pago fallo

ADR-023: Token Expiration -- Access 1h / Refresh 7d

Contexto: Se debe definir los tiempos de expiracion del access token JWT y del refresh token UUID para el portal.

Opciones consideradas:

  1. Access token largo (24h) sin refresh -- menos requests de renovacion, pero ventana de riesgo muy amplia si el token se compromete
  2. Access token 1h + refresh token 24h -- renovacion frecuente, sesion maxima corta
  3. Access token 1h + refresh token 7d -- balance entre seguridad y comodidad (el cliente no necesita re-loguearse cada dia)

Decision: Access token expira en 1 hora, refresh token expira en 7 dias.

Detalles:

  • Access token: JWT con exp = iat + 3600 (1 hora)
  • Refresh token: UUID con refresh_token_expires = NOW() + INTERVAL '7 days'
  • En cada refresh: se genera un NUEVO access token Y un NUEVO refresh UUID (rotacion de ambos)
  • El refresh token anterior se invalida al generar el nuevo (sobrescritura en portal_users)
  • Si el refresh token expira (7 dias sin actividad), el usuario debe re-loguearse

Razon:

  • 1 hora de access token limita la ventana de ataque si el JWT se compromete (localStorage)
  • 7 dias de refresh token permite sesiones largas sin re-login para clientes que usan el portal regularmente
  • La rotacion en cada refresh asegura que un refresh token robado solo puede usarse una vez
  • El refresh token en base de datos es revocable inmediatamente (SET NULL en logout)

ADR-024: Pago Parcial de Facturas -- Seleccion Libre con Montos Parciales

Contexto: Se debe definir si el cliente puede elegir que facturas pagar y si puede pagar montos parciales de una factura individual.

Opciones consideradas:

  1. Pago total obligatorio -- el cliente paga todo el saldo pendiente. Simple pero inflexible
  2. Seleccion de facturas, solo monto completo -- el cliente elige que facturas pagar, pero cada una se paga en su totalidad
  3. Seleccion libre con montos parciales -- el cliente elige que facturas pagar Y puede pagar un monto parcial de cualquiera

Decision: Seleccion libre con montos parciales.

Detalles:

  • El cliente puede seleccionar cuales facturas pagar (no esta obligado a pagar todas)
  • Por cada factura seleccionada, puede ingresar un monto parcial (debe ser > 0 y <= saldo de la factura)
  • Si no ingresa monto parcial, se asume el saldo completo de la factura
  • El total del pago es la suma de los montos seleccionados
  • Request schema: facturas: [{factura_id, monto}] donde monto puede ser menor o igual al saldo de la factura
  • El recibo creado cubre el monto parcial; el saldo restante queda como deuda pendiente
  • Multiples facturas pueden combinarse en un unico pago, cada una con monto completo o parcial

Razon:

  • Flexibilidad real para el cliente: puede pagar lo que su presupuesto le permita
  • Reduce friccion: no obliga a pagar todo o nada
  • Consistente con la funcionalidad de pagos parciales que ya existe en el ERP administrativo
  • El recibo parcial se registra normalmente en cuenta corriente via ReciboRelationsService

ADR-025: Perfil de Usuario -- Cambio de Password + Email/Telefono

Contexto: Se debe definir que datos de perfil puede ver y modificar el cliente desde el portal.

Opciones consideradas:

  1. Sin gestion de perfil -- los datos se manejan exclusivamente desde el admin. Limitante para el cliente
  2. Perfil completo editable -- el cliente modifica todos sus datos. Riesgo de inconsistencias con ordcon
  3. Perfil mixto -- datos de identidad (nombre, DNI/CUIT) solo lectura, datos de contacto y seguridad editables

Decision: Perfil mixto con datos de identidad de solo lectura y datos de contacto/seguridad editables.

Detalles:

  • Solo lectura (datos de ordcon, solo admin puede modificar): nombre, DNI/CUIT
  • Editables (datos de portal_users): email, telefono
  • Cambio de password: requiere verificacion del password actual antes de aceptar el nuevo
  • Endpoints nuevos:
    • GET /portal/perfil -- merge de datos de ordcon (nombre, DNI/CUIT) + portal_users (email, telefono, last_login)
    • PUT /portal/perfil -- actualizar email y/o telefono (valida formato de email)
    • PUT /portal/auth/cambiar-password -- requiere current_password + new_password, valida politica (8 chars + 1 numero)
  • Pagina nueva en el frontend: /perfil

Razon:

  • Nombre y DNI/CUIT son datos de identidad fiscal que no deben modificarse sin control administrativo
  • Email y telefono son datos de contacto que el cliente puede necesitar actualizar (cambio de linea, nueva casilla)
  • El cambio de password requiere verificacion del actual para prevenir cambios no autorizados (ej: sesion abierta en dispositivo compartido)

ADR-026: Emails Transaccionales -- Bienvenida + Confirmacion Pago + Reset Password

Contexto: El portal necesita enviar emails en ciertos eventos del ciclo de vida del usuario: registro exitoso, pago aprobado, y reset de password.

Opciones consideradas:

  1. Sin emails -- el cliente solo ve informacion dentro del portal. Pierde contexto fuera de la app
  2. Email para todo -- notificaciones por cada accion. Spam potencial y complejidad innecesaria
  3. Emails transaccionales acotados -- solo los 3 eventos criticos que requieren comunicacion fuera del portal

Decision: 3 tipos de emails transaccionales usando la configuracion de email existente del sistema Bautista.

Detalles:

  1. Bienvenida: Se envia al completar el registro exitoso. Contenido: nombre del cliente, nombre de la empresa (tenant), URL del portal
  2. Confirmacion de pago: Se envia cuando el webhook confirma un pago aprobado. Contenido: facturas pagadas (tipo + numero + monto), monto total, fecha de pago, numero de recibo generado
  3. Reset de password: Se envia al solicitar forgot-password. Contenido: codigo de 6 digitos, tiempo de expiracion (15 minutos)

Razon:

  • La bienvenida confirma al cliente que el registro fue exitoso y le da la URL para acceder
  • La confirmacion de pago da un comprobante por fuera del portal (el cliente puede no estar mirando la app cuando el webhook procesa)
  • El email de reset ya estaba documentado (ADR-015), pero se formaliza como email transaccional
  • Reutilizar la configuracion SMTP existente (ADR-013) mantiene la simplicidad operativa

Resumen de Decisiones

ADRTituloDecision
001RepositorioRepo independiente portal-usuarios (submodulo git)
002AutenticacionJWT con password (bcrypt), auto-registro contra ordcon
003BackendDDD Modules/Portal/ con sub-modulos Auth, Account, Payment, Cupon
004DeploymentDocker por tenant (frontend), backend compartido
005Base de datosportal_users + portal_payments al nivel de ordcon, sin tenant_domains
006AlcanceFeature completa, sin fases MVP
007CuponesReutilizar CuponPagoService + CuponValidacionService existentes
008Branding.env Docker + data_config del tenant
009Algoritmo JWTHS256 con PORTAL_JWT_SECRET separado (distinto al Admin UI)
010Refresh TokenUUID en portal_users, una sesion activa por usuario
011Webhook TenantMetadata en portal_payments, lookup por external_id
012CORSWildcard *, proteccion via JWT
013EmailReutilizar configuracion existente del sistema
014Lockout5 intentos fallidos -> 15 min de bloqueo
015Password PolicyMinimo 8 caracteres + al menos 1 numero
016Gateway de PagoPatron Adapter + Factory, seleccion por tenant, webhook gateway-agnostic
017Frontend UI Libraryshadcn/ui + Radix UI + Tailwind CSS, theming via CSS variables
018State ManagementTanStack Query (server state) + React Context (auth, branding)
019RouterTanStack Router, type-safe con search params validados via Zod
020Build per TenantBuild por tenant con VITE_* en build time, imagen Docker inmutable
021Cupon PDFBackend proxy stream, GET /portal/cupones/{id}/pdf, informes interno
022PollingTanStack Query refetchInterval (5-10s), sin WebSockets ni SSE
023Token ExpirationAccess token 1h, refresh token 7d, rotacion en cada refresh
024Pago ParcialSeleccion libre de facturas + montos parciales permitidos
025Perfil de UsuarioNombre/DNI solo lectura, email/telefono/password editables
026Emails TransaccionalesBienvenida, confirmacion de pago, reset de password

Diagrama de Resolucion de Schema (consolidado)

mermaid
sequenceDiagram
    participant F as Frontend Docker
    participant B as Backend
    participant I as ini.sistema
    participant DB as Tenant DB

    Note over F: .env tiene TENANT_ID=1, SUCURSAL_ID=1

    F->>B: Request con JWT<br/>{portal_user_id: uuid, tenant_id: 1, sucursal_id: 1}

    B->>I: tenant_id=1 -> DB name?
    I-->>B: "empresa_a"

    B->>I: sucursal_id=1 -> schema?
    I-->>B: "suc0001"

    B->>DB: Conectar a empresa_a, schema suc0001
    B->>DB: Ejecutar query
    DB-->>B: Resultados

    B-->>F: JSON response

Diagrama de Pago Automatico (consolidado)

mermaid
sequenceDiagram
    participant C as Cliente
    participant F as Frontend
    participant B as Backend (Payment/)
    participant GW as Gateway de Pago
    participant DB as Tenant DB

    C->>F: Selecciona facturas
    F->>B: POST /portal/pagos/iniciar

    B->>GW: Crear pago
    GW-->>B: URL de pago

    B->>DB: INSERT portal_payments (pending)
    B-->>F: {redirect_url}

    F->>C: Redirige a gateway
    C->>GW: Completa pago

    GW->>B: POST /portal/pagos/webhook
    B->>DB: UPDATE portal_payments (approved)
    B->>DB: Crear recibo en ctacte<br/>(ReciboRelationsService)

    B-->>GW: 200 OK

Diagrama del Stack Frontend (consolidado)

mermaid
graph TD
    subgraph "Build Time"
        ENV["Variables VITE_*<br/>(docker build --build-arg)"]
        Vite["Vite Build"]
        Bundle["Bundle estatico<br/>(HTML + JS + CSS)"]
        ENV --> Vite --> Bundle
    end

    subgraph "Runtime (nginx:alpine)"
        Nginx["nginx serve static"]
        Bundle --> Nginx
    end

    subgraph "Frontend App"
        TRouter["TanStack Router<br/>(type-safe routes)"]
        TQuery["TanStack Query<br/>(server state + polling)"]
        ShadcnUI["shadcn/ui<br/>(Radix + Tailwind)"]
        RHF["React Hook Form<br/>+ Zod"]
        AuthCtx["AuthContext<br/>(JWT localStorage)"]
        BrandCtx["BrandingContext<br/>(CSS variables)"]
        AxiosClient["Axios<br/>(JWT + tenant headers)"]
    end

    subgraph "Backend (compartido)"
        API["bautista-backend<br/>Modules/Portal/"]
        Informes["Informes Service<br/>(puerto 9999)"]
        API -->|proxy PDF| Informes
    end

    Nginx --> TRouter
    TRouter --> TQuery
    TQuery --> AxiosClient
    AxiosClient --> API
    BrandCtx -->|CSS vars| ShadcnUI
    RHF -->|schemas| ShadcnUI