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).
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
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.
travel vacío02El 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
Y en paralelo, fuera del pedido del usuario:
Qué hace cada repositorio
| Repo | Qué es | Su 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. |
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
El lenguaje de todo el backend del ecosistema V3.
Acá: los cuatro servicios de servidor (core, auth, integrations, workers) y el SDK compartido.
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.
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.
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.
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.
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
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.
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.
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.
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.
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).
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
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.
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.
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.
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.
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.
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
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.
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.
“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”.
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
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.
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.
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.
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.
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.
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.
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ó:
| Falla | Qué pasa |
|---|---|
El rol tiene BYPASSRLS | Las policies existen, están bien escritas… y no se aplican a ese usuario. Era el estado real del ecosistema hasta el 13/08. |
Falta FORCE | El dueño de la tabla se salta su propia policy. |
Falta WITH CHECK | Podés leer solo lo tuyo, pero insertar filas dentro del cliente de otro. |
| El código no activa el contexto | Sin use_rls, la conexión nunca declara el tenant. |
| Tabla nueva sin policy | La 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 sí se salta las policies. Es con el que corren Alembic y el trabajo previo al despliegue.
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:
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.
Booking flow — la transacción
Qué hay disponible hoy, a qué precio, y la reserva en sí. Cinco pasos:
HotelAvail → HotelBookingRules → HotelBooking →
ReadBooking → BookingCancel.
Los límites que impone el proveedor (medidos, no supuestos)
| Límite | Consecuencia práctica |
|---|---|
| Máximo 500 hoteles por consulta de disponibilidad | Buscar “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 contenido | Bajar 5.593 fichas son 224 llamadas encadenadas. |
| Buscar por zona de destino está vedado para nuestra integración | Hay 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 origen | Todo el tráfico sale por el proxy SOCKS5 de la IP registrada. |
| Refresco del catálogo cada ≤15 días, reprocesando todo | Es un compromiso escrito con Juniper. Lo cumple el worker semanal, y por eso existe una tabla que registra cada corrida. |
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.
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:
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.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.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.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.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)
service-integrations el volcado completo en NDJSON: zonas, ciudades,
portfolio y contenido. ~7,8 minutos.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.C · Una búsqueda por destino
POST /travel/search con el destino, las fechas y las habitaciones.service-integrations arma un XML por trozo, sale por el proxy y unifica las
respuestas.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
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.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.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.GET /travel/bookings y POST /travel/cancel. Al cancelar, el proveedor
informa cuánto cobró, y ese importe se muestra.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
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.
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.
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.
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
| Grupo | Tablas | Qué 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.
dev → ambiente de desarrollo. main → producción.
No hay un botón de “publicar” intermedio.dev-3716e8e.hinapsys-infra-cloud para que apunte a la
imagen nueva.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.
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.
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
| Fase | Qué dejó | Dónde está |
|---|---|---|
C0 · C-CRED · C-MOTOR · C1 · C2 | El adaptador Juniper multi-tenant y el motor mínimo de búsqueda y reserva | prod parcial |
C3 · S-CERT · S5 | Las dos certificaciones de Juniper, aprobadas | cerrado |
E-RLS | El aislamiento por tenant funcionando de verdad, con dos roles de base | prod |
S0 · S1 · S2 | Catálogo propio: descarga, modelo, worker semanal, autocompletar | prod (sin datos) |
P0 · S3 · S4 · S4b · S4c · S4d | El portal B2B completo: login, buscador, cotización, valoración, reserva, cancelación y motor de precios | dev |
Cola inmediata — lo que quedó abierto esta semana
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.
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.
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.
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.
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.
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
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.
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.
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).
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.
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.
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.
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.
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.
Generador de itinerarios, redactor y traductor de descripciones, lectura automática de itinerarios en imagen para importarlos como propuesta, y asistente para el operador.
Pipeline comercial de oportunidades con seguimiento obligatorio (sin actividad registrada no se avanza de etapa), asignación por destino y métricas por vendedor.
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.
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.
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.