Auditoría Travel — Estándares de Suscripciones y Monetización
Fecha: 2026-07-10
Alcance: apps/travel contra docs/standards/SV_Standard_Subscriptions.md (el qué/por qué) y SV_Standard_Subscriptions_Architecture.md (el cómo).
Implementación de referencia: apps/gastro/backend (dominio billing + core/entitlements.py).
Método: inspección de código + verificación ejecutada contra PostgreSQL real (alembic upgrade head + alembic check en BD limpia) y consulta del estado real del CI (gh run).
0. Resumen ejecutivo
⚑ Actualización 2026-07-11 — P0 y P1 IMPLEMENTADOS. - P0 (lo que estaba roto): drift migraciones↔modelos cerrado (mig. 0020) + CI reparado (llevaba ≥3 días rojo y enmascaraba el drift). 5 jobs verdes. - P1 (Capa 2 + Capa 3, ADR-011):
core/entitlements.py(feature-gating),core/subscriptions.py(ciclo de vida Stripe + enforcement), migración 0021 (vocabulario +billing_cycle/current_period_end+ grandfathering de kasiri-01), trial real de 7 días en el alta, y el paywall ya no es teórico:starterrecibe 402 al intentar cobrarle a un huésped. 21 tests nuevos. - P2 IMPLEMENTADO: las 4 capas están completas. Capa 1 (PLAN_CATALOGserver-side — el precio dejó de vivir en el frontend), checkout + webhook endurecido, Capa 4 (subscription_paymentsconraw_event+audit_log, ambas con RLS), y consentimiento legal. Y se cerró una vulnerabilidad real: el webhook de Wompi no era fail-closed — con la llave vacía (el default) cualquiera podía forjar un pago APPROVED. 22 tests nuevos. - Pendiente: llaves reales de Wompi (sin ellas el webhook rechaza todo, que es lo correcto), frontend del checkout (Widget) y dunning mínimo (§5.9).El diagnóstico de abajo describe el estado previo a esa implementación.
Veredicto (al momento de la auditoría): Travel NO tiene sistema de suscripciones. Tiene 3 columnas huérfanas que simulan tenerlo.
De las 4 capas que exige el estándar, Travel tiene cero completas:
| Capa | Estándar | Travel |
|---|---|---|
| 1 — Catálogo (precio server-side) | PLAN_CATALOG en el backend |
❌ No existe. El precio vive en el navegador (marketing.es.ts) — viola el Principio #1 |
| 2 — Entitlements (plan → features) | has_feature() / require_entitlement() → 402 |
❌ No existe. Cero feature-gating en todo el backend |
| 3 — Estado (en el tenant) | plan_type, subscription_status, billing_cycle, current_period_end |
⚠️ Parcial y muerto. 3 columnas que nunca se escriben, nunca se leen, y no están en ninguna migración |
| 4 — Evidencia (pagos + audit) | subscription_payments (raw_event) + audit_log |
❌ No existe. Ninguna de las dos tablas |
Dos hallazgos que hay que atender aunque no se construya billing todavía:
1. 🔴 Drift de schema real (verificado): las 3 columnas de suscripción están en el modelo pero en ninguna migración → cualquier entorno construido con alembic upgrade head crashea al consultar properties.
2. 🔴 El CI de Travel Backend está rojo (desde antes de esta sesión) y eso enmascara el punto 1: el gate alembic check que lo cazaría nunca llega a ejecutarse.
1. Contexto: Travel tiene DOS flujos de dinero (no confundirlos)
| Flujo | Quién paga | Estado en Travel |
|---|---|---|
| Reserva — el huésped le paga al hotel (depósito Wompi) | Huésped → Hotel | ✅ Existe: dominio payments (Payment.reservation_id, link de pago, webhook firmado con anti-replay 300s) |
| Suscripción SaaS — el hotel le paga a Timeliber | Hotel → Timeliber | ❌ No existe — es lo que cubren estos estándares |
Decisión de producto del fundador (2026-07-10): el primer tier NO incluirá el pago de huéspedes.
→ Consecuencia arquitectónica directa: el dominio payments (cobro al huésped) deja de ser una capacidad universal y pasa a ser una feature gateada por plan. Eso es exactamente lo que la Capa 2 (entitlements) existe para expresar — y Travel no tiene dónde escribirlo hoy. En la práctica: Feature.GUEST_PAYMENTS fuera del tier 1, con require_entitlement() → 402 en POST /reservations/{id}/payment.
2. Hallazgos por capa
Capa 1 — Catálogo: ❌ el precio vive en el navegador (viola Principio #1)
Los planes y precios están hardcodeados en el frontend, sin contraparte en el backend:
// apps/travel/frontend/marketing-site/src/shared/content/marketing.es.ts
{ id: "starter", price: "$120.000", priceSuffix: "COP / mes" }
{ id: "pro", name: "Expansión (Pro)", price: null } // "Próximamente"
{ id: "elite", name: "Trascendencia (Elite)", price: null } // "Próximamente"
- No existe
PLAN_CATALOGserver-side. No hay ninguna fuente de verdad de precios en el backend. - No existe checkout de suscripción (
POST /billing/checkout/session) ni webhook de suscripción. - ✅ Lo que SÍ cumple: son 3 tiers (Good-Better-Best, §1) y los dos superiores sin precio funcionan como "Próximamente" — ancla el precio sin mentir, tal como recomienda el estándar.
Capa 2 — Entitlements: ❌ no existe feature-gating
Grep en todo app/: cero ocurrencias de entitlement, PLAN_CATALOG, has_feature, require_entitlement, o un 402 de paywall. No hay ninguna capacidad de expresar "este plan no incluye X" — que es justamente lo que el fundador acaba de decidir para el tier 1.
Capa 3 — Estado: ⚠️ "paywall teórico" (el anti-patrón exacto del blueprint §4)
apps/travel/backend/app/domains/properties/models.py:60-62:
subscription_tier: Mapped[str] = mapped_column(String(20), nullable=False, default="starter") # starter, pro, elite
subscription_status: Mapped[str] = mapped_column(String(20), nullable=False, default="trial") # trial, active, past_due, canceled
trial_ends_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
Estas 3 columnas son completamente inertes. Las únicas 2 apariciones en todo el código son la definición del modelo y un schema de lectura (property_profile/schemas.py:327-329, que las expone en la API):
- ❌ Nunca se escriben:
auth/service.py(registro) no las toca → todo hotel nace con el default y ahí se queda. - ❌ Nunca se leen para decidir nada: ningún endpoint verifica si la suscripción está activa. Un tenant con
subscription_status='canceled'tiene exactamente el mismo acceso que uno activo. - ❌
trial_ends_atnunca se setea → el trial nunca termina (es NULL para todos). - ❌ Faltan
billing_cycleycurrent_period_end(el estándar §5.4 los exige). - ⚠️ Slugs genéricos:
starter/pro/elitees literalmente el patrón "Basic/Pro" que el estándar desaconseja (§1, nombres orientados a valor:carta/crece/opera). Curiosamente los nombres comerciales sí son de valor ("Expansión", "Trascendencia") — el problema es solo el slug canónico. Es el momento barato de cambiarlo: aún no hay ningún dato en producción que dependa de esos slugs.
Esto es, textualmente, el último incidente de la tabla §4 del blueprint: "Paywall teórico: estados de suscripción escritos pero jamás leídos en runtime". Travel está incluso un paso más atrás: ni siquiera se escriben.
Capa 4 — Evidencia: ❌ no existe
Sin tabla travel_subscription_payments (con wompi_transaction_id UNIQUE, raw_event JSONB, amount_in_cents BIGINT) y sin tabla de audit log en toda la vertical. No hay dónde registrar la evidencia de un pago de suscripción ni el before/after de un cambio de estado.
Consentimiento legal: ❌ no se captura (riesgo regulatorio)
RegisterRequest (auth/schemas.py:85-93) pide email, password, nombre y hotel — no captura legal_accepted, y no existen terms_accepted_at/version/ip en ninguna tabla. El estándar (§5.8) lo exige por Decreto 1377/2013 art. 8. Gastro ya lo tiene resuelto.
3. 🔴 Hallazgo crítico fuera del alcance: drift de schema + CI rojo que lo enmascara
3.1 Las columnas de suscripción no existen en las migraciones (verificado)
Las 3 columnas están en el modelo SQLAlchemy pero en ninguna migración de Alembic. Verificado contra PostgreSQL real:
# BD limpia construida SOLO con migraciones:
alembic upgrade head
SELECT column_name FROM information_schema.columns
WHERE table_name='properties' AND column_name LIKE '%subscription%';
→ (vacío)
SELECT id, subscription_tier FROM properties LIMIT 1;
→ ERROR: column "subscription_tier" does not exist
Impacto: cualquier entorno construido desde migraciones (staging, un prod nuevo, un dev que clona limpio) crashea en cuanto la app consulta una Property — SQLAlchemy hace SELECT de todas las columnas mapeadas.
Por qué nadie lo notó:
- Los tests pasan porque conftest.py construye el schema con Base.metadata.create_all() (desde el modelo), no con migraciones.
- La prod actual (kasiri-01) también se construyó con create_all → tiene las columnas y funciona.
- El DATABASE_SCHEMA.md auto-generado sale del modelo, así que también las muestra → el doc miente sobre lo que las migraciones producen.
alembic check (que compara modelo ↔ migraciones) falla y detecta exactamente esto:
('add_column', 'properties', Column('subscription_tier', ...))
('add_column', 'properties', Column('subscription_status', ...))
('add_column', 'properties', Column('trial_ends_at', ...))
nullable en varias tablas.)
3.2 El CI de Travel Backend lleva días rojo, y por eso el gate nunca corrió
.github/workflows/travel-backend-ci.yml sí tiene el gate correcto (- name: Sin drift baseline ↔ modelos (alembic check)). Pero el job muere antes de llegar ahí:
Los jobs migrations y reference-docs no definen las 5 variables de entorno de R2 (R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_S3_ENDPOINT, R2_BUCKET, R2_PUBLIC_URL), que en config.py son Field(...) requeridas → Settings() explota al importar la app.
Estado real (gh run list): el CI falla en cada push desde al menos el 2026-07-08 (antes de la sesión de auditoría SV+RLS). En la corrida del 07-08 fallaban los 5 jobs; hoy fallan migrations y reference-docs (los commits P0-P3 arreglaron quality).
Nota de honestidad: en los commits P0-P3 reporté "gates verdes" — eso era cierto para los gates que corrí localmente (pytest 359, ruff, mypy strict), pero no verifiqué el CI, que ya estaba rojo por esta causa pre-existente. El gate
alembic checkes precisamente el que habría cazado el drift de las columnas de suscripción.
4. Lo que Travel SÍ tiene y es reutilizable
No todo es deuda. El dominio payments (cobro al huésped) ya resolvió bien la parte difícil de un webhook de Wompi, y ese código es el molde para el webhook de suscripción:
- ✅ Verificación de firma con
hmac.compare_digest. - ✅ Ventana anti-replay de 300s (BE-4) — exactamente lo que pide el blueprint §2.2.
- ✅ Idempotencia por referencia +
Payment.reference UNIQUE. - ✅ Vertical slicing correcto (
router → service → repository, Protocols) — el mismo layout que exige el blueprint §2.1. - ✅ Secretos como
SecretStr;wompi_events_key/wompi_integrity_keyya en config.
5. Roadmap de remediación priorizado
P0 — Arreglar lo que está roto (independiente de si se construye billing)
- Migración para las 3 columnas de suscripción (o eliminarlas del modelo si se van a rediseñar). Sin esto, ningún entorno nuevo arranca. Aditiva, sin downtime.
- Reparar el CI: añadir las 5 vars de R2 (dummy) a los jobs
migrationsyreference-docs, o —mejor— darles default a losField(...)de R2 enconfig.pypara que la app importe sin ellas. Con el CI verde,alembic checkvuelve a proteger contra drift. - Resolver el resto del drift que reporta
alembic check(índices,nullable).
P1 — Cimientos de suscripción (el orden del checklist §5 del estándar)
- Decidir la métrica de valor de Travel (¿habitaciones? ¿reservas/mes?) y congelar los 3 slugs canónicos — momento barato: no hay datos en prod que dependan de
starter/pro/elite. Recomendación: slugs orientados a valor, alineados con los nombres comerciales que ya se publicitan. - Capa 2 primero, no la 1:
core/entitlements.pyconFeature.GUEST_PAYMENTSfuera del tier 1 (la decisión que ya tomó el fundador) +require_entitlement()→ 402 enPOST /reservations/{id}/payment. Esto da valor antes de que exista un solo cobro. - Capa 3 completa: añadir
billing_cycle+current_period_end; escribir el estado al registrar (trial de 7-14 días contrial_ends_atreal) y leerlo (require_active_subscriptioncomo dependency en los routers de mutación; GET pasa). - Capa 1:
PLAN_CATALOGserver-side en centavos; el marketing-site deja de ser la fuente de precios. - Capa 4: tabla
travel_subscription_payments+travel_audit_log; webhook de suscripción reusando el hardening del webhook de reservas. - Consentimiento legal en el registro (
legal_accepted+terms_accepted_at/version/ip) — Decreto 1377/2013. - Tests obligatorios (§3 del blueprint) + verificar contra BD real con rol no-superuser (lección del incidente RLS de Gastro; Travel ya tiene ese patrón en
test_rls_isolation.py).
Aviso RLS (ADR-010): al crear
travel_subscription_paymentsytravel_audit_log, ambas deben entrar enapp/core/rls.py::TENANT_TABLESo documentarse como exentas. Elaudit_loges el caso que rompió a Gastro: el webhook es un flujo sin request de tenant, así que necesitaset_tenant_context(bypass=True)(que Travel ya tiene) o fijar el GUC del tenant identificado por la referencia del pago.