Appearance
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:
- Directorio dentro de
bautista-app/-- menor overhead de repos, pero acopla portal con ERP admin - 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:
- Identificacion por DNI/CUIT (sin password) -- simple pero inseguro, cualquiera con un DNI accede
- JWT con password (bcrypt) -- seguridad real, misma mecanica que Admin UI
- 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 viaini.sistema;sucursal_id-> schema - Auto-registro: DNI/CUIT debe coincidir con un
ordconexistente. 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
ordconevita 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:
- Legacy
App\Controller\Portal\*-- rapido de implementar pero inconsistente con la direccion del proyecto - 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 JWTAccount/-- consulta de cuenta corriente, deudas pendientes, datos del clientePayment/-- inicio de pago, procesamiento de webhooks, creacion automatica de recibosCupon/-- 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:
- App generica multi-tenant con resolucion por dominio -- una instancia, tabla
tenant_domains, wildcard DNS - Docker por tenant (frontend-only), backend compartido -- una instancia Docker por tenant con
.env, backend unico - 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
.envcon: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:
- Tablas en schema
publicde cada DB -- simple pero no respeta la estructura multi-schema del sistema - Tablas al mismo nivel de schema que
ordcon-- consistente con la arquitectura existente, dinamico por tenant - 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 conordcon)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
ordconpermite foreign keys directas - Es consistente con la arquitectura multi-schema existente del sistema
- Cada tenant puede tener
ordconen un nivel diferente;portal_userssigue 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:
- MVP + fases incrementales -- entrega temprana pero feature incompleta (portal sin pagos no tiene mucho valor)
- 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:
- Crear nuevos servicios de cupones para el portal -- duplicacion innecesaria
- Reutilizar servicios existentes -- consistencia, sin duplicacion
- Tabla
portal_cuponesdedicada -- 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/enModules/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:
- Branding en tabla
tenant_domains-- requiere tabla que no existe (ADR-004) - Branding en
.envdel Docker +data_configdel tenant -- simple, sin tablas extra - 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_configdel tenant en la DB puede complementar o extender la configuracion base- El frontend usa
.envcomo valores por defecto (disponibles sin request al backend) - Si el backend provee config adicional via API, el frontend puede combinarla
Razon:
.envda branding inmediato sin depender del backend (primera carga rapida)data_configpermite cambios dinamicos sin re-deploy del contenedor- No se necesita tabla nueva:
data_configya 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:
- 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
- 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:
- Sin refresh token -- simple pero mala UX (re-login cada hora)
- Refresh token como JWT -- mas complejo, requiere blacklist
- 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:
- Query params en la URL del webhook (
?tenant_id=1&sucursal_id=1) -- simple pero expone informacion, facil de manipular - Resolucion por dominio de origen -- complejo, no todos los gateways envian dominio
- Metadata en
portal_payments-- al crear el pago se almacenatenant_id+sucursal_iden la fila; al recibir webhook se busca porexternal_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:
- CORS por tenant -- cada deploy tiene su dominio en
.env, el backend lo lee y configuraAccess-Control-Allow-Originespecifico - 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:
- Servicio de email independiente para el portal -- aislamiento pero duplicacion de configuracion
- 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:
- 3 intentos -- agresivo, puede frustrar usuarios legitimos que confunden password
- 5 intentos -- balance entre seguridad y usabilidad
- 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:
- 8 caracteres + 1 mayuscula + 1 numero + 1 caracter especial -- demasiado estricto para clientes finales
- 8 caracteres + 1 numero -- balance entre seguridad y usabilidad
- 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:
- Gateway unico hardcodeado -- rapido pero limita a un solo proveedor, cambiar requiere reescribir el service
- 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:
PaymentGatewayInterfacecon 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_gatewaydel 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) ypayment_gateway_config(JSON con credenciales) enini.sistema - Campo
gatewayen 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_iddel payload ->portal_payments.gateway-> factory -> adapter - Field mapping: Cada adapter mapea campos internamente. Ejemplo: PagoTIC retorna
form_urlcomoredirect_urldel DTO; MercadoPago retornariainit_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
gatewayenportal_paymentselimina 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:
- shadcn/ui + Radix UI + Tailwind CSS -- componentes headless copiados al proyecto, estilizados via CSS variables de Tailwind. Theming completo via variables CSS
- MUI (Material UI) -- componentes pre-estilizados con Material Design, theming via
createTheme(). Override de estilos requieresxprop ostyled(). Tema de Material visible por defecto - 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 leerimport.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:
- TanStack Query + React Context -- TQ para server state (cache, refetch, mutations, polling), Context para auth y branding
- 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
- 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
refetchIntervalnativamente, 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:
- React Router v7 -- router mas popular, API conocida. Search params como strings sin tipado
- 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:
validateSearchen 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
beforeLoadque lanzaredirect()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:
- Build per tenant -- cada imagen Docker se compila con
--build-argque setea las variablesVITE_*. El build de Vite reemplazaimport.meta.env.VITE_*por los valores literales en el bundle - 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 dewindow.__CONFIG__en vez deimport.meta.env
Decision: Build per tenant. Cada imagen Docker se compila con las variables VITE_* del tenant.
Razon:
- Simplicidad:
import.meta.envfunciona 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 installse cachea, solo se re-ejecuta elvite buildcon 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:
- Generacion en frontend (jsPDF) -- generar el PDF directamente en el navegador del cliente. No requiere backend
- 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
- 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:
- Frontend llama a
GET /portal/cupones/{id}/pdfcon JWT en header - Backend valida JWT, resuelve tenant, verifica que el cupon pertenece al usuario
- Backend llama internamente a
http://localhost:9999/cupon/{id}(servicio de informes) - Backend retorna el PDF como stream con
Content-Type: application/pdf - 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:
- Refresh manual -- el usuario recarga la pagina para ver el estado actualizado. Simple pero mala UX
- 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 - WebSockets -- conexion persistente bidireccional. El backend notifica al frontend cuando el webhook actualiza el pago
- 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):refetchIntervalretornafalse
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:
refetchIntervales un parametro estandar del hookuseQuery. 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:
- Access token largo (24h) sin refresh -- menos requests de renovacion, pero ventana de riesgo muy amplia si el token se compromete
- Access token 1h + refresh token 24h -- renovacion frecuente, sesion maxima corta
- 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:
- Pago total obligatorio -- el cliente paga todo el saldo pendiente. Simple pero inflexible
- Seleccion de facturas, solo monto completo -- el cliente elige que facturas pagar, pero cada una se paga en su totalidad
- 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}]dondemontopuede ser menor o igual alsaldode 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:
- Sin gestion de perfil -- los datos se manejan exclusivamente desde el admin. Limitante para el cliente
- Perfil completo editable -- el cliente modifica todos sus datos. Riesgo de inconsistencias con ordcon
- 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-- requierecurrent_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:
- Sin emails -- el cliente solo ve informacion dentro del portal. Pierde contexto fuera de la app
- Email para todo -- notificaciones por cada accion. Spam potencial y complejidad innecesaria
- 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:
- Bienvenida: Se envia al completar el registro exitoso. Contenido: nombre del cliente, nombre de la empresa (tenant), URL del portal
- 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
- 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
| ADR | Titulo | Decision |
|---|---|---|
| 001 | Repositorio | Repo independiente portal-usuarios (submodulo git) |
| 002 | Autenticacion | JWT con password (bcrypt), auto-registro contra ordcon |
| 003 | Backend | DDD Modules/Portal/ con sub-modulos Auth, Account, Payment, Cupon |
| 004 | Deployment | Docker por tenant (frontend), backend compartido |
| 005 | Base de datos | portal_users + portal_payments al nivel de ordcon, sin tenant_domains |
| 006 | Alcance | Feature completa, sin fases MVP |
| 007 | Cupones | Reutilizar CuponPagoService + CuponValidacionService existentes |
| 008 | Branding | .env Docker + data_config del tenant |
| 009 | Algoritmo JWT | HS256 con PORTAL_JWT_SECRET separado (distinto al Admin UI) |
| 010 | Refresh Token | UUID en portal_users, una sesion activa por usuario |
| 011 | Webhook Tenant | Metadata en portal_payments, lookup por external_id |
| 012 | CORS | Wildcard *, proteccion via JWT |
| 013 | Reutilizar configuracion existente del sistema | |
| 014 | Lockout | 5 intentos fallidos -> 15 min de bloqueo |
| 015 | Password Policy | Minimo 8 caracteres + al menos 1 numero |
| 016 | Gateway de Pago | Patron Adapter + Factory, seleccion por tenant, webhook gateway-agnostic |
| 017 | Frontend UI Library | shadcn/ui + Radix UI + Tailwind CSS, theming via CSS variables |
| 018 | State Management | TanStack Query (server state) + React Context (auth, branding) |
| 019 | Router | TanStack Router, type-safe con search params validados via Zod |
| 020 | Build per Tenant | Build por tenant con VITE_* en build time, imagen Docker inmutable |
| 021 | Cupon PDF | Backend proxy stream, GET /portal/cupones/{id}/pdf, informes interno |
| 022 | Polling | TanStack Query refetchInterval (5-10s), sin WebSockets ni SSE |
| 023 | Token Expiration | Access token 1h, refresh token 7d, rotacion en cada refresh |
| 024 | Pago Parcial | Seleccion libre de facturas + montos parciales permitidos |
| 025 | Perfil de Usuario | Nombre/DNI solo lectura, email/telefono/password editables |
| 026 | Emails Transaccionales | Bienvenida, 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 responseDiagrama 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 OKDiagrama 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