Manual técnico · agosto 2026TravelTech, explicado de arriba hacia abajo

Una plataforma para que un mayorista de turismo conecte sus propias credenciales de proveedores (bancos de camas, hoteleras, agregadores) y le dé un buscador y un motor de reservas white-label a su red de agencias minoristas. Construida sobre HINAPSYS V3. Primer cliente: ROAD Tour Operador, con 44 agencias, y su primer proveedor conectado, Itaparica Tour (tecnología Juniper).

9
repos involucrados
~30
tablas del schema travel
4.046
hoteles en el catálogo de ROAD
dev
ambiente donde vive hoy

Qué problema resuelve

Una agencia minorista que quiere vender un hotel en Cancún tiene que entrar a la web del mayorista, o a un agregador, o pedirle presupuesto por WhatsApp a un operador. El mayorista, del otro lado, negocia contratos con proveedores (bancos de camas) que exponen su inventario por API, pero esas APIs son suyas: la agencia no puede usarlas directamente.

TravelTech se mete en el medio: el mayorista carga una vez sus credenciales de proveedor, y sus 44 agencias buscan, cotizan y reservan contra ese inventario desde un portal con la marca del mayorista, a un precio que el mayorista define con una fórmula, y con la comisión de cada agencia calculada y registrada automáticamente.

Los cuatro actores

🏢 El mayorista (ROAD)

Dueño del contrato con el proveedor y de la credencial. Define el precio de venta con una fórmula y ve el neto (lo que le cuesta). En el modelo de datos es un tenant de tipo WHOLESALER.

🏪 El minorista (una de las 44 agencias)

Entra al portal del mayorista, busca y reserva usando la credencial heredada. Ve el precio de venta, nunca el costo. Es un tenant RETAILER hijo, y cada sucursal suya es una company.

🛏 El proveedor (Itaparica, vía Juniper)

Tiene el inventario real: hoteles, tarifas, disponibilidad. Se habla con él por SOAP/XML y exige certificación, IP autorizada y respeto de sus límites técnicos.

👤 El viajero

Todavía no toca el sistema. Su portal (itinerario, documentos, pagos online) está diseñado y diferido: es la parte E4/E5 del roadmap.

Dónde está hoy, en una frase

Estado a 2026-08-15

El circuito comercial completo funciona en el ambiente dev: una agencia entra al portal con la marca de ROAD, escribe “Cancún”, ve hoteles reales con precio de venta calculado por fórmula, valora una tarifa, reserva, ve su reserva y la cancela. Nada de esto está en producción todavía, y el go-live está frenado por decisión propia, no por un bloqueo externo.

Ya certificado por JuniperEl flujo de reserva (#130643, 10/08) y la Static Data (opción 1)
Ya en producciónSolo la infraestructura de base: aislamiento por tenant (RLS), roles de base de datos, schema travel vacío
En dev y andandoPortal B2B, catálogo, buscador, valoración, reserva, cancelación, motor de precios
Lo que falta para venderCargar la credencial en producción, correr el importador, promover el código y decidir el go-live

02El mapa de piezas

TravelTech no es una aplicación: son ocho piezas que se hablan entre sí. Esta sección es el mapa que conviene tener en la cabeza antes de mirar cualquier flujo.

El recorrido de una búsqueda, en una línea

Portal B2BReact · lo que ve la agencia
backend-corePython · decide y orquesta
service-integrationsPython · traduce a XML
Proxy SOCKS5la IP autorizada
Juniper / Itaparicael inventario real

Y en paralelo, fuera del pedido del usuario:

workersCelery · una vez por semana
service-integrationsbaja el catálogo entero
PostgreSQLcatálogo propio
Meilisearchautocompletar “cancn”

Qué hace cada repositorio

RepoQué esSu responsabilidad en TravelTech
hinapsys-travel-portal-b2b
dev
Aplicación web React 19 + Vite El portal white-label que usa el agente del minorista: login con la marca del mayorista, buscador, ficha de hotel, cotizaciones, valoración, reserva, “mis reservas” y cancelación. Toda la UI de venta vive acá.
hinapsys-backend-core
prod dev
API en Python (FastAPI) El cerebro. Resuelve permisos, aplica el aislamiento por tenant, traduce “Cancún” a códigos de hotel, llama al proveedor, aplica el precio de venta, guarda cotizaciones, reservas y comisiones.
hinapsys-service-integrations
dev
Microservicio Python El único que sabe hablar Juniper. Arma el XML, lo manda por el proxy, parsea la respuesta y devuelve JSON limpio. Si mañana entra otro proveedor, el cambio es acá y nadie más se entera.
hinapsys-workers
prod
Procesos de fondo (Celery) Baja el catálogo completo del proveedor una vez por semana, lo guarda, lo indexa para el autocompletar y resuelve la cola de pedidos de fotos. Nadie lo dispara: corre solo.
hinapsys-service-auth
prod
API de identidad (FastAPI) Usuarios, contraseñas, tenants, empresas, permisos y emisión de tokens. Es también quien sabe qué dominio web pertenece a qué mayorista.
hinapsys-python-sdk
prod
Librería Python compartida El código que no puede divergir entre servicios: conexión a la base con contexto de tenant, cifrado de credenciales, resolución de credenciales de proveedor y el importador del catálogo.
hinapsys-infra-cloud
prod
Manifiestos de Kubernetes La descripción declarativa de todo lo que corre en el cluster. Lo que está acá es lo que existe: si no está escrito, no está desplegado.
hinapsys-web-platform
congelado
ERP Angular de HINAPSYS Tiene una sección travel que se usó para certificar con Juniper. Quedó congelada como herramienta interna: no se le agrega nada, la venta vive en el portal React.
hinapsys-db Scripts SQL de la plataforma Los roles de base de datos y utilidades de ambiente local. Es donde vive el SQL que crea hinapsys_app y hinapsys_migrator.
Por qué hay dos frontends

La frontera no es “administración vs. venta”, sino quién opera la pantalla. Angular (web-platform) es el ERP que usa ROAD internamente. React (travel-portal-b2b) es lo que ve la agencia, con la marca del mayorista y en su propio dominio. Cuando se decidió mover el buscador de Angular a React (2026-08-08) fue justamente para no construir la misma pantalla dos veces.

03Diccionario de tecnologías

Cada ficha responde tres cosas: qué es, para qué sirve en general, y qué papel juega exactamente acá. No hace falta saber usarlas: alcanza con saber qué hace cada una y por qué está.

Backend y lenguaje

Python lenguaje

El lenguaje de todo el backend del ecosistema V3.

Acá: los cuatro servicios de servidor (core, auth, integrations, workers) y el SDK compartido.

FastAPI framework web

Framework para construir APIs HTTP en Python. Valida lo que entra, documenta sola la API y es asíncrona (puede atender otras llamadas mientras espera al proveedor).

Acá: es lo que expone POST /api/v1/travel/search y todos los demás endpoints. Lo asíncrono importa: una búsqueda tarda 9 segundos esperando a Juniper, y el servidor no se queda tildado.

Pydantic validación

Define la “forma” esperada de los datos que entran y salen de la API, y rechaza lo que no encaja.

Acá: es lo que hace que una fecha mal escrita se rechace con un error claro en el borde (un 422 que el que la mandó puede corregir) en vez de reventar más adentro.

SQLAlchemy ORM

Un ORM: describe las tablas de la base como clases de Python, para no escribir SQL a mano en todos lados.

Acá: src/models/travel.py es la definición de las ~30 tablas del módulo. Ojo con una trampa que ya nos mordió: lo que el modelo no declara, la herramienta de migraciones lo propone borrar.

Alembic migraciones

Lleva el control de versiones de la estructura de la base: cada cambio es un archivo con su “ida” y su “vuelta”, y la base recuerda en qué versión está.

Acá: las migraciones se aplican solas al desplegar (ArgoCD corre un trabajo previo). La versión actual de core en producción es un identificador tipo f3c9d21a7e64.

asyncpg driver

El conector asíncrono entre Python y PostgreSQL. Es muy rápido, y muy literal: no adivina tipos.

Acá: es la fuente de una familia entera de bugs. Una fecha mandada como texto contra una columna date revienta; un NULL sin tipo declarado hace que PostgreSQL corte la consulta. Cosas que “andaban” en los tests con otro conector.

Datos

PostgreSQL base de datos

La base de datos relacional de V3. Alojada como servicio gestionado en Vultr.

Acá: dos bases distintas — la de auth (usuarios, tenants, dominios) y la de core (negocio, con los schemas core y travel). Que sean dos explica por qué no hay relaciones directas entre una tabla de travel y la tabla de tenants de auth.

RLS — Row Level Security aislamiento

Una función de PostgreSQL que filtra filas dentro de la base: aunque la consulta pida “todo”, la base devuelve solo lo que el contexto de esa sesión permite ver.

Acá: es lo único que separa a un tenant de otro — todos conviven en la misma base. Ver la sección 04, es el concepto más importante de toda la plataforma.

Meilisearch motor de búsqueda

Un buscador de texto pensado para autocompletar: tolera errores de tipeo y responde en milisegundos.

Acá: hace que escribir cancn devuelva Cancún. Con una búsqueda SQL normal (LIKE 'cancn%') el resultado sería cero. No tiene RLS: adentro conviven los documentos de todos los tenants y lo único que los separa es un filtro que arma el servidor, nunca el cliente.

Redis memoria / cola

Almacén clave-valor en memoria. Sirve como caché y como cola de mensajes.

Acá: es la cola por donde el planificador le pasa trabajo al worker. Detalle que costó: el Redis que ya existía en el cluster estaba configurado para descartar claves cuando se llena — perfecto para un caché, catastrófico para una cola. El worker tiene el suyo propio.

Celery tareas de fondo

El sistema estándar de Python para ejecutar trabajos fuera del pedido web: “hacé esto ahora”, “hacé esto todas las semanas”.

Acá: corre la importación semanal del catálogo (8 minutos) y la cola de descarga de fotos. Tiene dos partes: el worker (hace el trabajo) y el beat (el reloj que lo dispara).

Fernet cifrado

Un esquema de cifrado simétrico: una sola clave cifra y descifra.

Acá: la contraseña que ROAD tiene con Itaparica se guarda cifrada en la base. Solo se descifra en el momento de llamar al proveedor. La clave es una por ambiente, no por tenant, así que el texto cifrado es portable entre tenants del mismo ambiente — eso permitió mover la credencial de un tenant a otro sin que nadie manipulara la contraseña real.

Frontend

React 19 framework UI

La librería para construir interfaces web como componentes reutilizables que se re-dibujan solos cuando cambian los datos.

Acá: todo el portal B2B. La sesión del usuario, por ejemplo, es “estado de React” (AuthContext), porque el login de 3 pasos deja al usuario a mitad de camino y la pantalla tiene que saberlo.

Vite empaquetador

La herramienta que toma el código fuente y produce los archivos que el navegador descarga. En desarrollo, además, refresca la pantalla al instante.

Acá: lo que se mide con él es el bundle: el peso de la primera carga. Bajó de 537 kB a 259 kB al sacar una librería que se cargaba entera para usar una sola función.

TypeScript lenguaje

JavaScript con tipos: el editor y el compilador avisan si un dato no tiene la forma esperada, antes de ejecutar nada.

Acá: src/api/types.ts es el contrato del portal con la API. Cuando la API cambió el significado de price_net, fue el lugar donde se vio.

Tailwind CSS v4 estilos

Sistema de estilos por clases utilitarias: en vez de escribir hojas de estilo, se componen clases pequeñas directamente en el HTML.

Acá: es lo que permite que los colores del portal salgan de variables CSS que se pisan con el branding de cada mayorista, sin recompilar nada.

Radix UI componentes

Componentes de interfaz “sin estilo” pero correctos: menús, diálogos, etiquetas — con el comportamiento de teclado y accesibilidad ya resuelto.

Acá: la base de los desplegables y modales del portal, para no reescribir esa plomería.

Syncfusion EJ2 componentes

Suite comercial de componentes ricos (grillas, calendarios, gráficos). Ya se usa en el resto del ecosistema HINAPSYS.

Acá: presente pero mínimo. Es pesado, así que en el portal se lo mantiene fuera del camino crítico de la primera carga.

Integración con el proveedor

SOAP / XML protocolo

Un estilo de API anterior al JSON: los mensajes son documentos XML dentro de un “sobre”, y cada operación tiene su nombre declarado en una cabecera especial (SOAPAction).

Acá: es como habla Juniper y no es negociable. Todo el XML vive encapsulado en service-integrations: ni el portal ni backend-core ven una etiqueta XML jamás.

Proxy SOCKS5 red

Un intermediario de red: el tráfico sale “por” otra máquina, y por lo tanto con la IP de esa máquina.

Acá: Juniper autoriza por IP de origen y la IP habilitada es la de un VPS (216.238.102.139), no la del cluster de Kubernetes. Sin el proxy, el proveedor bloquea. Y la configuración del proxy viaja cifrada dentro de la credencial, porque cada proveedor puede autorizar una IP distinta.

NDJSON formato

“JSON delimitado por líneas”: un objeto JSON completo por renglón. Permite procesar un archivo gigante línea por línea, sin cargarlo entero en memoria.

Acá: el volcado del catálogo son 146 MB. Viaja en NDJSON y la última línea es siempre __meta__: como la respuesta empieza a enviarse antes de saber si todo salió bien, esa última línea es la única forma honesta de decir “ojo, esto quedó incompleto”.

JWT autenticación

Un “pase” firmado digitalmente que el cliente presenta en cada llamada. Contiene quién sos y qué podés hacer, y no se puede falsificar sin la clave de firma.

Acá: además de la identidad, el token lleva cifrados adentro los datos de conexión a la base del tenant. Consecuencia poco intuitiva: cambiar una credencial de base no afecta a los tokens ya emitidos, que siguen entrando con la anterior hasta que vencen.

Infraestructura y despliegue

Docker contenedores

Empaqueta una aplicación con todo lo que necesita para correr en una “imagen”, que se ejecuta igual en cualquier máquina.

Acá: cada servicio es una imagen etiquetada con el ambiente y el commit: dev-3716e8e, prod-fdf4ffb. Esa etiqueta es la trazabilidad: dice exactamente qué código está corriendo.

Kubernetes (VKE) orquestador

El sistema que corre los contenedores: los reinicia si se caen, los distribuye entre servidores y les asigna cuánta CPU y memoria pueden usar.

Acá: 3 nodos en Vultr, São Paulo. Los límites de recursos son reales y ya mordieron: service-integrations moría al parsear 117.216 zonas porque tenía 128 MB asignados.

Kustomize (base / overlays) configuración

Permite tener una descripción base de la infraestructura y “parches” por ambiente, en vez de copiar todo dos veces.

Acá: con una trampa importante — en infra-cloud cada rama es un ambiente: main es producción, dev es dev. Fusionar una en la otra llevaría las imágenes de dev a producción.

ArgoCD (GitOps) despliegue

GitOps: el repositorio es la única fuente de verdad de lo que debe estar corriendo. ArgoCD compara lo que dice Git con lo que hay en el cluster y corrige la diferencia solo.

Acá: nadie despliega a mano. Un cambio hecho a mano en el cluster se revierte solo en la siguiente sincronización — cosa que ya pasó y confundió a todos un rato. También es ArgoCD quien corre las migraciones de base antes de levantar la versión nueva.

GitHub Actions CI/CD

Automatizaciones que se disparan con cada push: correr los tests, construir la imagen, actualizar el manifiesto de infraestructura.

Acá: un push a dev despliega a dev; un push a main despliega a producción. No hay botón intermedio: sincronizar ramas es publicar.

Vultr nube

El proveedor de infraestructura: servidores, Kubernetes gestionado y bases PostgreSQL gestionadas.

Acá: São Paulo, por latencia con Paraguay. Sus particularidades importan: el usuario administrador de la base no es superusuario, y su connection pool está atado a un único usuario — dos cosas que obligaron a rediseñar la puesta en marcha de los roles.

04Multi-tenant: quién ve qué

Si hay un solo concepto que hay que entender de toda la plataforma, es este. Todos los clientes conviven en la misma base de datos, y lo único que los separa es una regla escrita dentro de PostgreSQL. Cuando esa regla falta, nada falla: simplemente el aislamiento no existe.

Dos jerarquías, y son distintas

Eje comercial — tenant

Dice a quién le compra el minorista. ROAD es un tenant WHOLESALER; cada agencia minorista es un tenant RETAILER que cuelga de él por parent_tenant_id.

Eje societario — company

Dice qué sucursal está operando. Si un mismo dueño tiene dos agencias, es un tenant con dos companies. Las reservas y cotizaciones se atribuyen a la company, no solo al tenant.

Por qué importa

Filtrar solo por tenant le mostraría a la agencia 1 las cotizaciones de la agencia 2 del mismo dueño. Por eso el portal opera con un token company-scoped: sabe no solo quién sos, sino desde qué sucursal estás trabajando.

RLS: el aislamiento vive en la base, no en el código

La alternativa clásica sería que cada consulta agregue “...y que sea de mi cliente”. El problema es que basta una consulta donde alguien se olvide para filtrar datos entre clientes. La decisión del ecosistema fue ponerlo dentro de PostgreSQL: cada tabla tiene una policy, y la conexión declara al empezar la transacción “estoy operando como el tenant X”. La base hace el resto.

-- lo que el SDK emite al abrir cada transacción
SET LOCAL app.current_tenant   = '<uuid del tenant>';
SET LOCAL app.visible_tenants  = '<el tenant + el de su mayorista>';

-- y lo que la policy de cada tabla evalúa
USING      ( tenant_id = ANY(core.visible_tenant_ids()) )   -- qué puedo LEER
WITH CHECK ( tenant_id = app.current_tenant )               -- qué puedo ESCRIBIR

De ahí sale la asimetría central del negocio: el minorista lee el catálogo de su mayorista, pero solo escribe en el suyo.

Las cinco formas de romper el aislamiento sin que se note

Todas estaban presentes en algún momento, y ninguna produce un error. Vale la pena conocerlas porque explican por qué la fase E-RLS existió:

FallaQué pasa
El rol tiene BYPASSRLSLas policies existen, están bien escritas… y no se aplican a ese usuario. Era el estado real del ecosistema hasta el 13/08.
Falta FORCEEl dueño de la tabla se salta su propia policy.
Falta WITH CHECKPodés leer solo lo tuyo, pero insertar filas dentro del cliente de otro.
El código no activa el contextoSin use_rls, la conexión nunca declara el tenant.
Tabla nueva sin policyLa herramienta de migraciones crea la tabla y no gestiona policies. Nadie avisa. Es la más fácil de repetir.

Dos roles de base de datos, y por qué no puede ser uno

hinapsys_app — el runtime

Solo puede leer y escribir datos, no puede crear ni alterar tablas, y no puede saltarse las policies. Es con el que entran la API y el worker.

hinapsys_migrator — las migraciones

Dueño de los schemas y se salta las policies. Es con el que corren Alembic y el trabajo previo al despliegue.

Por qué dos

Con FORCE ROW LEVEL SECURITY ni el dueño de la tabla se salva de la policy. Un backfill de datos corriendo con el rol de la aplicación vería cero filas y terminaría “bien” sin haber migrado nada. Los requisitos son literalmente opuestos, y por eso son dos usuarios distintos.

La herencia de credencial: el detalle que hace funcionar todo

La agencia minorista no tiene contrato con Itaparica: usa el del mayorista. Cuando el sistema resuelve “¿con qué credencial busco?”, sube por la jerarquía y encuentra la de ROAD. Pero hay un matiz que costó descubrir:

Darle credencial propia al minorista es peor que no darle ninguna

Los mapeos entre “nuestro” hotel y el código del proveedor cuelgan de la credencial. Con una credencial propia, esos mapeos no existirían y la búsqueda devolvería 200 OK con cero resultados: “este destino no tiene hoteles” — que parece un dato y no lo es. Lo que hace que la herencia funcione es que el identificador de credencial devuelto sea el del dueño.

Y tiene una consecuencia de negocio: el markup vive en esa credencial, así que el minorista también hereda el markup. Es una decisión de autorización, no de plomería.

05Cómo se habla con el proveedor

Juniper es una plataforma que usan muchos proveedores de turismo. Itaparica corre sobre Juniper. Eso significa que un solo adaptador nuestro sirve para cualquier proveedor de esa red — pero también que hay que jugar con sus reglas, que son bastante estrictas.

El modelo comercial: buyer una vez, N sellers

HINAPSYS se certificó una sola vez como buyer (comprador de la API). Cada proveedor que ROAD sume después es un seller que se conecta a esa certificación ya obtenida: no hay que recertificar. Lo que sí hay por cada seller nuevo es una licencia de conexión (USD 875 única + 18/mes) que paga el seller.

Las dos familias de llamadas

Static Data — el catálogo

Qué hoteles existen, cómo se llaman, dónde quedan, cómo son. Se baja entero y se guarda. Cinco operaciones: ZoneList, CityList, HotelPortfolio, HotelContent, HotelCatalogueData.

Producción de Itaparica: 233.339 registros, 239 llamadas, 146 MB, 7,3 minutos.

Booking flow — la transacción

Qué hay disponible hoy, a qué precio, y la reserva en sí. Cinco pasos: HotelAvailHotelBookingRulesHotelBookingReadBookingBookingCancel.

Es el flujo que Juniper certificó el 10/08.

Los límites que impone el proveedor (medidos, no supuestos)

LímiteConsecuencia práctica
Máximo 500 hoteles por consulta de disponibilidadBuscar “Brasil” son 1.500 códigos ⇒ hay que partirlo en 3 llamadas y unir los resultados. Sin partirlo, no devuelve menos: devuelve error.
Máximo 25 hoteles por consulta de contenidoBajar 5.593 fichas son 224 llamadas encadenadas.
Buscar por zona de destino está vedado para nuestra integraciónHay que mandar la lista explícita de códigos de hotel. De ahí que el catálogo propio no sea opcional.
Autoriza por IP de origenTodo el tráfico sale por el proxy SOCKS5 de la IP registrada.
Refresco del catálogo cada ≤15 días, reprocesando todoEs un compromiso escrito con Juniper. Lo cumple el worker semanal, y por eso existe una tabla que registra cada corrida.
La trampa que esto esconde

Los dos primeros rechazos llegan como HTTP 200 con cero resultados, con un código de error REQ_PRACTICE escondido en el cuerpo de la respuesta. Desde arriba se ve exactamente igual que “no hay disponibilidad”. Por eso el adaptador lee el código de error antes de concluir nada.

Qué significó “certificar”

Dos certificaciones distintas, las dos ya obtenidas:

  • Booking flow (#130643, 10/08): correr tres casos de prueba completos y mandar capturas. Aprobado.
  • Static Data, opción 1: Juniper exige que el nombre, la dirección y la categoría del hotel que el cliente ve al valorar y al reservar vengan de la respuesta de la API en vivo, y no de nuestro catálogo guardado. Es una regla de producto, no de código, y sigue vigente: el catálogo propio sirve para descubrir (buscar, filtrar, autocompletar), nunca para completar la pantalla de confirmación.
Por qué valió la pena certificar la Static Data

Habilita conectar a cualquier proveedor de la red Juniper sin negociar un acuerdo bilateral de uso de datos con cada uno. Sin eso, cada seller nuevo de ROAD sería una negociación aparte.

06Los flujos, paso a paso

Cinco recorridos que ya funcionan de punta a punta en dev. Cada uno indica qué pieza hace qué, para poder ubicar dónde mirar cuando algo falla.

A · El login del portal, que son tres pasos y no uno

El portal vive en un dominio del mayorista y tiene que mostrar su marca antes de que nadie se loguee. Eso obliga a un baile en varios tiempos:

0
La marca, sin token
El portal pregunta GET /travel/public/branding?host=…. Es público. El servidor traduce el dominio a un mayorista y devuelve colores, logo y contacto. Si el dominio existe pero no tiene marca cargada, devuelve colores por defecto — nunca un error: el login tiene que abrir igual.
1
Pre-login: ¿a qué organizaciones pertenece este usuario?
POST /auth/pre-login con la cabecera X-Portal-Host. El servidor resuelve por su cuenta de quién es ese portal y filtra la lista.
2
Token de organización
POST /auth/token-for-tenant. Acá se revalida la jerarquía, porque el cliente elige qué organización manda y nada le impide mandar una que no le ofrecieron.
3
Token de sucursal (company-scoped)
POST /auth/companies/{id}/token. Este es el token con el que opera el portal. Si el usuario tiene una sola sucursal — el caso de casi todas las agencias de ROAD — el paso ocurre igual, pero es invisible.
El agujero que este diseño cerró

Antes, el pre-login solo miraba que la contraseña fuera válida y el tenant estuviera activo. Como el minorista pertenece a su propio tenant y no al del mayorista, un usuario de una agencia de otro mayorista entraba al portal de ROAD sabiendo únicamente la URL. La lección de diseño: el portal no puede decir “pertenezco a ROAD” y esperar que el servidor le crea — el servidor lo resuelve solo, a partir del dominio.

B · La importación del catálogo (corre sola, una vez por semana)

1
El reloj dispara
Celery beat pone un mensaje en la cola de Redis. El worker lo levanta.
2
Enumera el trabajo por tenant
Empieza por la tabla de tenants — la única sin policy, y lo es a propósito — y recién con el contexto abierto pregunta qué credenciales hay. Al revés devolvería cero y la corrida terminaría “bien” sin importar nada.
3
Descarga
Le pide a service-integrations el volcado completo en NDJSON: zonas, ciudades, portfolio y contenido. ~7,8 minutos.
4
Importa reprocesando todo
No es incremental: se reprocesa el catálogo entero (el proveedor no informa qué cambió, y además es lo que se comprometió por escrito). Lo que no llegó, se da de baja. ~30 segundos.
5
Indexa para el autocompletar
Sube destinos y hoteles a Meilisearch. Antes borra lo del tenant: si no, un hotel dado de baja seguiría apareciendo en el buscador para siempre.
6
Deja constancia
Una fila en catalog_import_runs con la ventana partida en descarga e importación, y el estado: OK, PARTIAL o FAILED. Es lo que responde “¿cuándo se refrescó esta conexión?” sin leer logs.

Medido en el cluster: 5.593 productos y 1.855 destinos en 8,3 minutos. PARTIAL es un estado normal: casi siempre hay uno o dos hoteles del portfolio sin ficha de contenido.

C · Una búsqueda por destino

1
El agente escribe “cancn”
Con cada tecla, el portal llama al autocompletar, que consulta Meilisearch y devuelve destinos y hoteles en una sola respuesta. Si Meilisearch no responde, cae a una búsqueda SQL básica y lo declara en la respuesta: “no encuentro” y “no pude preguntar” son cosas distintas.
2
Elige Cancún y busca
POST /travel/search con el destino, las fechas y las habitaciones.
3
core resuelve el destino a códigos
Baja recursivamente por el árbol de destinos (Cancún incluye sus barrios y zonas hijas), junta los hoteles, resuelve la credencial heredada del mayorista y traduce cada hotel al código del proveedor usando los mapeos de esa credencial.
4
Se parte en trozos de ≤500 y se lanza en paralelo
service-integrations arma un XML por trozo, sale por el proxy y unifica las respuestas.
5
Se arma el resultado: establecimiento → ofertas[]
No una lista plana de tarifas, sino un hotel con sus N ofertas, cada una con su proveedor, su tarifa, su régimen y si es reembolsable. Ese contrato se eligió con un solo proveedor conectado, a propósito: con el segundo sería rediseñar la API y el frontend enteros.
6
Se aplica el precio de venta
Cada oferta pasa por el motor de fórmulas. Al minorista le llega el PVP; el costo ni siquiera viaja en la respuesta.

Medido: Cancún = 30 establecimientos y 7.646 ofertas en 9,2 s. Brasil al tope (1.500 códigos, 3 trozos, 13,8 MB) = 241 establecimientos y 6.852 ofertas en 10,3 s.

Tres “no sé” que la pantalla no puede colapsar

Una búsqueda son varias llamadas, así que si un trozo falla la pantalla muestra menos hoteles — igual que si no tuvieran lugar. Por eso la respuesta trae un estado: OK (“el proveedor no ofrece nada”), PARTIAL (“faltan hoteles, y no porque estén llenos”) y FAILED (“no hubo respuesta”). Lo mismo con la ocupación ausente y con la política de cancelación desconocida: se muestran como tercer estado, nunca como cero ni como “no”.

D · Valorar, reservar y cancelar

1
Valoración
POST /travel/valuation. Se le vuelve a preguntar al proveedor por esa tarifa: confirma precio y recién acá entrega la política de cancelación, que no viene en la disponibilidad. El nombre, dirección y categoría que se muestran salen de esta respuesta — es la regla certificada.
2
Escalones de cancelación
Se parsean a fecha → importe → moneda en el adaptador, no como el párrafo de texto que manda el proveedor. Eso habilita después mostrar “fecha límite” y automatizar cancelaciones sin volver a tocar la integración.
3
Datos de pasajeros y reserva
POST /travel/book. Las edades tienen que coincidir con lo valorado o el proveedor rechaza. La reserva se guarda en travel.bookings con su localizador.
4
Mis reservas y cancelación
GET /travel/bookings y POST /travel/cancel. Al cancelar, el proveedor informa cuánto cobró, y ese importe se muestra.
Confirmada allá, perdida acá

La primera reserva hecha desde la pantalla se confirmó en el proveedor y no se guardó: el portal mandaba la etiqueta del régimen (“Sólo Alojamiento”) donde va el código (SA), y no entraba en la columna. El proveedor dijo OK y el guardado local reventó después. Dos consecuencias de diseño: si el proveedor confirma y nuestro guardado falla, la respuesta es la reserva marcada como no persistida y no un error — un error diría que la reserva no existe, y existe. Y: probar por API no equivale a probar por pantalla, porque la pantalla manda campos que la API no mandaba.

E · El precio de venta y la comisión

Lo que parecía “aplicar un porcentaje” resultó ser un motor de fórmulas. La fórmula real de ROAD es:

neto del proveedor × 1.025   (gastos bancarios)    → rol COST
                   × 1.10    (margen mayorista)    → rol MARGIN
                   ÷ 0.89    (comisión 11 % de la agencia) → rol COMMISSION
                   → redondeo al múltiplo de 10 superior   → rol ROUNDING
Por qué un porcentaje plano no alcanzaba

1.025 × 1.10 ÷ 0.89 = 1.2670: un markup plano del 26,70 % daría exactamente el mismo precio. Lo que no podría decir es qué parte de ese 26,70 es comisión de la agencia — que es lo único que hay que liquidarle. Saldría el precio bien y la contabilidad mal. Por eso cada paso lleva un rol.

Y la comisión no es “lo que agregó el paso”

Sobre un neto de 100, el ÷0.89 deja 126,69 y el redondeo lleva el PVP a 130. La comisión del 11 % es 14,30 (el 11 % de lo que se vende), no 13,94. Los 36 centavos son redondeo, y el redondeo no es de la agencia. Con miles de reservas es plata liquidada de menos sin que el número se vea mal.

Otras dos cosas que resuelve el motor:

  • La cascada: la fórmula se busca primero para esa agencia, después para su grupo, y al final la del mayorista por defecto.
  • Congelar la cuenta: cada reserva guarda el paso a paso del cálculo y una fila en el libro de comisiones. Si mañana cambia la fórmula, la reserva vieja sigue explicándose sola.

Verificado sobre 12.838 ofertas reales: cero diferencias contra la cuenta hecha aparte, cero ventas por debajo del costo, y la comisión es el 11 % del PVP en todas.

Dato operativo

Sin fórmula cargada, la venta es el neto — o sea que el minorista vería el costo de su mayorista. No se bloquea (una configuración faltante no puede dejar el portal sin cotizar), pero se avisa. En producción todavía no hay ninguna fórmula cargada.

07El modelo de datos

Dos bases PostgreSQL separadas, y dentro de la de negocio, dos schemas (que son como carpetas de tablas). Saber qué vive dónde explica varias decisiones que de otro modo parecen arbitrarias.

Base de auth

Usuarios, contraseñas, roles y permisos, tenants con su jerarquía (parent_tenant_id, tenant_type), portal_domains (qué dominio pertenece a qué mayorista) y database_connections (dónde vive la base de cada tenant).

Base de core

Schema core: tenants (espejo), empresas, personas/entidades. Schema travel: todo el módulo de turismo, ~30 tablas.

Consecuencia de que sean dos bases

No puede haber una relación de integridad entre una tabla de travel y la tabla de tenants de auth: viven en servidores distintos. Por eso las referencias apuntan a core.tenants, que es una copia local. Es un detalle que hizo desviarse del diseño original más de una vez.

Las tablas del schema travel, agrupadas por para qué existen

GrupoTablasQué guardan
Conexión al proveedor providers, provider_credentials El registro global de proveedores (sin dueño) y la credencial de cada mayorista: usuario, secreto cifrado, endpoints propios y proxy. Un índice especial impide que existan dos credenciales globales para el mismo par tenant+proveedor.
Catálogo propio destinations, destination_closure, products El árbol de destinos vendibles y los hoteles. destination_closure es un truco de rendimiento: precalcula “qué destinos cuelgan de cuál”, para no recorrer el árbol en cada búsqueda.
Traducción a códigos del proveedor provider_product_mappings, provider_destination_mappings Nuestro hotel ↔ el código del proveedor. Cuelgan de la credencial, no del proveedor, porque el inventario cambia según con qué contrato se pregunte.
Operación del catálogo catalog_media_requests, catalog_import_runs La cola de “bajame todas las fotos de este hotel” (las fotos son ~420 mil, no se bajan todas por defecto) y la bitácora de cada corrida de importación.
Venta quotes, quote_items, bookings La cotización persistente (que es a la vez “carrito”, “presupuesto guardado” y “venta abandonada”) y las reservas confirmadas con su localizador.
Precios y comisiones pricing_formulas, pricing_age_modifiers, agency_groups, agency_group_members, destination_zones, commission_ledger Las fórmulas con su alcance (agencia / grupo / por defecto), las zonas comerciales que las condicionan y el libro de comisiones a liquidar.
Marca y trazabilidad portal_brandings, provider_call_logs Los colores y el logo de cada portal, y una fila por cada llamada al proveedor — incluidas las que fallan antes de que exista una reserva a la que colgarlas.

Dos decisiones de modelo que vale la pena conocer

El árbol vendible es el 1,6 % del árbol del proveedor

Juniper devuelve 117.216 zonas de 224 países — es su árbol global, no el del proveedor conectado. Solo 1.460 tienen al menos un hotel (1.855 sumando los ancestros necesarios). Ese es el subárbol que guardamos, y la marca de “acá hay producto” la calculamos nosotros: el proveedor no la da.

Los códigos del proveedor no entran en las tablas de catálogo

El identificador de hotel de Juniper vive en la tabla de mapeos, nunca en products. Es lo que permite que mañana el mismo hotel llegue por dos proveedores distintos sin duplicarlo. La contracara: el portal no puede mandar un código de proveedor, manda nuestro identificador y core traduce.

08Del código a producción

Es un circuito automático de punta a punta. Entenderlo importa porque tiene una propiedad incómoda: sincronizar ramas es publicar.

1
Push a la rama del ambiente
dev → ambiente de desarrollo. mainproducción. No hay un botón de “publicar” intermedio.
2
GitHub Actions construye
Corre los tests, construye la imagen Docker y la etiqueta con el ambiente y el commit: dev-3716e8e.
3
Actualiza el repo de infraestructura
El propio workflow edita el manifiesto de hinapsys-infra-cloud para que apunte a la imagen nueva.
4
ArgoCD lo nota y sincroniza
Ve que el cluster no coincide con Git y lo corrige. Primero corre un trabajo previo (PreSync) que aplica las migraciones de base con el rol migrador; si sale bien, se borra solo y recién entonces levanta la versión nueva.
Tres reglas que se aprendieron a golpes

1 · El SDK se promueve primero. Core, auth y workers instalan la librería compartida desde la rama de su propio ambiente. Promover una app sin haber promovido antes el SDK construye la imagen de producción contra el SDK viejo — y si la diferencia es de comportamiento y no de forma, no falla: hace lo que no corresponde.

2 · En infraestructura, cada rama es un ambiente. Fusionar dev en main ahí llevaría las imágenes de desarrollo a producción. Ya pasó una vez.

3 · Lo que se toca a mano en el cluster, ArgoCD lo revierte. La corrección permanente va en Git, siempre.

Y una curiosidad que vale como concepto

Un trabajo PreSync no puede depender de una configuración que esa misma sincronización crea: ArgoCD corre el trabajo antes de aplicar el resto. El resultado es un despliegue que se bloquea a sí mismo, esperando algo que él mismo iba a crear.

09Lecciones que costaron caro

Casi todos los problemas serios de este proyecto comparten una forma: no fallan. Devuelven un número plausible, una lista vacía o un “éxito”. Es el patrón que más conviene tener presente al revisar cualquier cosa nueva.

1 · Una policy perfecta sobre un rol que la esquiva

El aislamiento por tenant estaba escrito, revisado y documentado. Pero el usuario con el que la aplicación entraba a la base tenía el permiso de saltarse las policies. Resultado: aislamiento cero, y todo indicando que estaba bien. Verificar con el usuario real, no con el de administración.

2 · Autorizado con una identidad, ejecutado con otra

Dentro del mismo pedido convivían dos formas de leer el token: el chequeo de permisos leía la cabecera, y la resolución de a qué empresa pertenecés leía la cookie. Con las dos distintas, el pedido pasaba el control con una identidad y accedía a los datos de otra. Y no era hipotético: el ERP y el portal comparten dominio de cookie.

3 · Un “200 OK” que significa error

Dos rechazos del proveedor (buscar por zona; mandar más de 500 códigos) llegan como respuesta exitosa con cero resultados. Vistos desde arriba, son indistinguibles de “no hay disponibilidad”.

4 · Una columna corta que perdió una reserva

El proveedor confirmó, y el guardado local falló porque un texto de 16 caracteres no entraba en una columna de 10. La reserva quedó viva del lado del proveedor y sin registro nuestro. El mismo tipo de problema apareció con el identificador de tarifa: mide hasta 3.000 caracteres y estaba en una columna de 255.

5 · El importador que da de baja lo que no llegó

La descarga del catálogo viaja en streaming, y la señal de “esto quedó incompleto” es la última línea. Un consumidor que la ignore importa 3.000 hoteles de 5.593 y da de baja los otros 2.593, con la corrida marcada como exitosa.

6 · Un paréntesis que casi anula el markup

En la primera versión del intérprete de fórmulas, (1.05) se leía como “condición verdadera”. El paso habría multiplicado por 1 y el precio habría salido sin markup, sin ningún error. (Y sí: las fórmulas se evalúan con un intérprete propio y limitado, nunca con la función de evaluación del lenguaje — es una cadena que carga un cliente.)

7 · La herramienta de migraciones propone borrar lo que el modelo no declara

Un comando de rutina proponía eliminar el índice que impide tener dos credenciales globales para el mismo proveedor. Sin ese índice, el sistema elegiría una de las dos al azar: no falla, se conecta con credenciales impredecibles.

8 · El dato que se mueve bajo los pies

El catálogo de Itaparica pasó de 5.593 a 4.046 hoteles en doce horas, con la misma credencial. Se verificó que el cambio era del proveedor. Conclusión operativa: un conteo de catálogo no es un número estable y no sirve como control de integridad.

La regla general que dejan todas

Cuando algo importante “anda”, la pregunta útil no es ¿falla? sino ¿cómo se vería si estuviera mal? En casi todos estos casos, se vería exactamente igual que ahora.

10Lo que falta del roadmap

El proyecto se ejecuta en fases con nombre corto. Las que están hechas cubren el circuito de venta contra un proveedor. Lo que sigue son tres cosas distintas: cerrar la salida a producción, completar la plataforma comercial, y escalar a varios proveedores.

Hecho hasta hoy, para ubicarse

FaseQué dejóDónde está
C0 · C-CRED · C-MOTOR · C1 · C2El adaptador Juniper multi-tenant y el motor mínimo de búsqueda y reservaprod parcial
C3 · S-CERT · S5Las dos certificaciones de Juniper, aprobadascerrado
E-RLSEl aislamiento por tenant funcionando de verdad, con dos roles de baseprod
S0 · S1 · S2Catálogo propio: descarga, modelo, worker semanal, autocompletarprod (sin datos)
P0 · S3 · S4 · S4b · S4c · S4dEl portal B2B completo: login, buscador, cotización, valoración, reserva, cancelación y motor de preciosdev

Cola inmediata — lo que quedó abierto esta semana

Cargar la fórmula de precios de ROADpendiente

Ya está cargada en dev. En producción no hay ninguna, y sin fórmula la venta es el neto: el minorista vería el costo de su mayorista. El sistema avisa, pero el aviso no lo arregla.

Definir el descuento por edad con Oscardecisión

El mecanismo está operativo y sin regla de cálculo: el proveedor manda un total por habitación sin desglose por pasajero, así que “50 % al menor” no tiene sobre qué aplicarse sin inventar la porción del menor. Está preparado para que definirlo sea cargar un valor, no migrar.

Credencial de producción y catálogo de producciónpendiente

La base de producción no tiene ni el proveedor ni ninguna credencial cargada, así que su catálogo está vacío. Es una decisión explícita, no un olvido: el worker lo levanta solo en cuanto exista.

La tarifa no reembolsabledecisión

Es el 21 % del inventario. Falta darle su propio tramo en la fórmula (la variable ya está disponible y ninguna fórmula la usa) y averiguar si el proveedor la rechaza igual en producción — averiguarlo cuesta una reserva real.

Dominio propio del mayoristapendiente

Hoy el portal vive bajo un subdominio nuestro. Para viajes.roadtour.com falta ampliar la lista de dominios permitidos de la API y darle a cada dominio su entrada y su certificado.

Secretos del cluster en texto planoproyecto aparte

Los secretos están versionados en el repositorio de infraestructura. Lo más expuesto son las dos claves con las que se firma un token válido y se descifra la conexión a la base que va adentro. Hay un proyecto abierto para sacarlos; nada ejecutado.

Fases planificadas y no empezadas

C4Salida a producción de la conexión Itaparicafrenada a propósito

Registrar la IP en el portal de Juniper, cargar las credenciales de producción cifradas y promover el código. No hay bloqueantes externos: Juniper aprobó la certificación e Itaparica autorizó la salida. Lo que falta es propio, y la decisión fue no salir hasta tener el catálogo funcionando y operable desde el portal.

Depende de: promoción del código a main · datos de producción
E0Completar los fundamentos de base de datospendiente

Las tablas del módulo se fueron creando de a poco, según las iba necesitando cada fase. Falta el resto del esquema diseñado (incluido el schema crm) y ampliar la tabla de solicitudes de producto de 9 a 22 columnas.

E2Back office del mayorista (Angular)pendiente

Hoy todo se configura por SQL o por script: las credenciales de proveedor, la marca del portal, las fórmulas de precio, los grupos de agencias. E2 es la pantalla para que ROAD haga eso solo, más la carga de productos manuales (lo que el mayorista arma sin proveedor: paquetes, excursiones propias).

Repos: backend-core + web-platform · Depende de: E0
E3aSolicitudes y creador de propuestaspendiente

El otro flujo comercial, el que no pasa por el buscador: la agencia pide (“familia de 4 a Río en enero, presupuesto X”), el mayorista recibe el pedido, lo asigna a un operador y este arma una propuesta visual con servicios, fotos y precios. Es la parte que hoy se hace por WhatsApp y planilla.

Repos: backend-core + web-platform · Depende de: E2
E3bPortal B2B: pedidos y propuestaspendiente

La otra mitad, en el portal de la agencia: cargar el pedido, leer la propuesta que le armó el mayorista y clonarla con su propio margen y su marca para reenviársela a su cliente final. Acá aparece el segundo nivel de markup (minorista → viajero), que el motor actual todavía no cubre.

Repo: travel-portal-b2b · Depende de: E3a
E1Almacenamiento de archivos y biblioteca de contenidodiferida

Un depósito propio de imágenes y documentos, y una biblioteca de bloques reutilizables (descripciones, fotos, condiciones) para armar propuestas sin reescribir todo cada vez. Hoy del catálogo se guardan URLs del proveedor, no los archivos.

E4Pagos onlinediferida

Links de pago con la marca de quien vende, integración con MercadoPago y Stripe, planes en cuotas con vencimientos, QR y conciliación automática de lo cobrado.

E5Portal del viajero (app instalable)diferida

La pantalla del pasajero: itinerario, vouchers, documentos, mapas y notificaciones, funcionando sin conexión e instalable desde el navegador. Se crea solo al confirmarse la reserva.

E6IA aplicada al turismodiferida

Generador de itinerarios, redactor y traductor de descripciones, lectura automática de itinerarios en imagen para importarlos como propuesta, y asistente para el operador.

E7CRM turísticodiferida

Pipeline comercial de oportunidades con seguimiento obligatorio (sin actividad registrada no se avanza de etapa), asignación por destino y métricas por vendedor.

E8Plataforma multi-proveedor completadiferida

Es el salto grande, y el que justifica varias decisiones ya tomadas: consultar varios proveedores en paralelo y fusionar resultados, adaptadores nuevos (W2M, Hotelbeds, Amadeus), deduplicación de un mismo hotel que llega por dos vías, caché de disponibilidad, control de ritmo de llamadas y el buscador embebible en sitios de terceros.

Ya adelantado de esta fase: Celery, Redis y Meilisearch (los necesitaba el catálogo)
F3Ideas de tercera fasesin fecha

Producto escolar, contratos legales, widget de precio de venta al público para sitios externos, integración con sistemas de gestión hotelera y analítica sobre el ERP.

Lo que no es una fase pero decide el ritmo

Hay dos cosas comerciales abiertas que no dependen de código: la respuesta de Tailorbeds/Mandarina a la solicitud de credenciales que hizo ROAD por su cuenta (sería el segundo y tercer proveedor), y la postura sobre quién paga la licencia de conexión de cada seller nuevo — hoy la paga el seller, y absorberla exigiría subir de nivel de suscripción con Juniper.

TravelTech — Manual técnico. Escrito a partir de la documentación viva del proyecto (hinapsys-docs/Proyectos/Iniciativas/Activo/traveltech/) y del código real en los repos, al 15 de agosto de 2026. Si algo cambió después, la fuente de verdad es roadmap_ejecutable.md y la ficha estado.md del proyecto.