Saltar a contenido

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: starter recibe 402 al intentar cobrarle a un huésped. 21 tests nuevos. - P2 IMPLEMENTADO: las 4 capas están completas. Capa 1 (PLAN_CATALOG server-side — el precio dejó de vivir en el frontend), checkout + webhook endurecido, Capa 4 (subscription_payments con raw_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_CATALOG server-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_at nunca se setea → el trial nunca termina (es NULL para todos).
  • Faltan billing_cycle y current_period_end (el estándar §5.4 los exige).
  • ⚠️ Slugs genéricos: starter/pro/elite es 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.

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', ...))
(+ drift adicional de índices y 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í:

pydantic_core.ValidationError: 5 validation errors for Settings

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(...) requeridasSettings() 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 check es 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_key ya en config.

5. Roadmap de remediación priorizado

P0 — Arreglar lo que está roto (independiente de si se construye billing)

  1. 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.
  2. Reparar el CI: añadir las 5 vars de R2 (dummy) a los jobs migrations y reference-docs, o —mejor— darles default a los Field(...) de R2 en config.py para que la app importe sin ellas. Con el CI verde, alembic check vuelve a proteger contra drift.
  3. Resolver el resto del drift que reporta alembic check (índices, nullable).

P1 — Cimientos de suscripción (el orden del checklist §5 del estándar)

  1. 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.
  2. Capa 2 primero, no la 1: core/entitlements.py con Feature.GUEST_PAYMENTS fuera del tier 1 (la decisión que ya tomó el fundador) + require_entitlement() → 402 en POST /reservations/{id}/payment. Esto da valor antes de que exista un solo cobro.
  3. Capa 3 completa: añadir billing_cycle + current_period_end; escribir el estado al registrar (trial de 7-14 días con trial_ends_at real) y leerlo (require_active_subscription como dependency en los routers de mutación; GET pasa).
  4. Capa 1: PLAN_CATALOG server-side en centavos; el marketing-site deja de ser la fuente de precios.
  5. Capa 4: tabla travel_subscription_payments + travel_audit_log; webhook de suscripción reusando el hardening del webhook de reservas.
  6. Consentimiento legal en el registro (legal_accepted + terms_accepted_at/version/ip) — Decreto 1377/2013.
  7. 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_payments y travel_audit_log, ambas deben entrar en app/core/rls.py::TENANT_TABLES o documentarse como exentas. El audit_log es el caso que rompió a Gastro: el webhook es un flujo sin request de tenant, así que necesita set_tenant_context(bypass=True) (que Travel ya tiene) o fijar el GUC del tenant identificado por la referencia del pago.