Recorrido pedagógico · Paso 3 de 13

De un evento a un KPI

Seguimos un solo pasaje, desde el instante en que se vende hasta que aparece como un número en el tablero de dirección. Siete paradas, cuatro capas, una sola historia.

1 venta real seguida 7 paradas Webhook → MicroStrategy
Cómo leer esta presentación. Es el recorrido aplicado: pone en movimiento el modelo que explica Cómo modelamos los datos. Cada sección es una parada del viaje del dato; los bloques oscuros muestran, simplificados, el JSON o el SQL que ocurre en cada una.

Sección 02 · El caso

Una venta, como punto de partida

Para que el recorrido sea concreto, fijamos un ejemplo y lo seguimos sin soltarlo.

El hecho del mundo real

El 3 de junio de 2026, en la agencia Andesmar Mendoza Centro, un pasajero compra un boleto Mendoza → Buenos Aires para viajar el 10 de junio. Paga $45.000 ARS en efectivo. Betterez registra la transacción TX-9F3A21 con el boleto BR-7781234.

El destino: un número de gestión

Esa venta, sumada a las otras del día, tiene que terminar contribuyendo +1 pasaje pagado y +$45.000 en la celda correcta del tablero "Ventas por agencia y día". El recorrido es cómo ese hecho llega, intacto y bien clasificado, hasta esa celda.

WebhookRawOLTPETLMétricaTablero
WebhookRawOLTPETLMétricaTablero
1
Captura · escucha en tiempo casi real

Betterez nos avisa

En el momento de la compra, Betterez emite un webhook: un POST con el detalle de la transacción, firmado criptográficamente.

POST /api/v1/webhooks/betterez
// cabecera con firma HMAC-SHA256 X-Betterez-Signature: "a3f9...e21c" { "event": "transaction.created", "data": { "transaction": { "transactionId": "TX-9F3A21", "total": 45000, "currencyCode": "ARS", "agencyName": "Andesmar Mendoza Centro", "status": "paid" }, "tickets": [{ "ticketNumber": "BR-7781234", "from": "Mendoza", "to": "Buenos Aires", "travelDate": "2026-06-10", "status": "paid" }], "payments": [{ "type": "cash", "amount": 45000 }] } }

¿Qué es un webhook?

Como recibir un mensaje en el instante en que pasa algo. No esperamos a un batch nocturno: el dato entra en segundos.

La firma HMAC

La cabecera X-Betterez-Signature prueba que el evento viene realmente de Betterez y no fue alterado. La API la valida antes de aceptar nada.

Una transacción trae todo junto: la cabecera de venta, sus boletos y sus pagos viajan en el mismo payload. En las próximas paradas se separan en tablas.
WebhookRawOLTPETLMétricaTablero
2
Capa Raw · GCS + raw_events

Guardar el original, antes de interpretar

La regla más sagrada del sistema: el JSON completo se persiste tal cual llegó antes de extraer una sola tabla de negocio. Es el principio raw-first.

Lo que ocurre, en orden
# 1. hash del payload (idempotencia) idempotency_key = sha256(payload) → "9b2f...a7" # 2. ¿ya lo vimos? → si sí, se descarta SELECT 1 FROM raw_events WHERE idempotency_key = '9b2f...a7'; # 3. JSON inmutable a Cloud Storage gs://andesmar-raw/2026/06/03/TX-9F3A21.json # 4. fila de auditoría INSERT INTO raw_events (gcs_path, transaction_id, event_type, status) VALUES ('gs://...', 'TX-9F3A21', 'transaction.created', 'received');

Por qué primero el original

Si mañana cambia una regla de BI, reprocesamos desde este JSON. El dato histórico es intocable: nunca se pierde lo que Betterez dijo.

Idempotencia: nunca dos veces

Betterez reintenta los webhooks. Mismo payload → mismo hash → se reconoce como duplicado y se descarta. Un evento jamás se cuenta dos veces.

Trazabilidad total

Desde cualquier número del tablero se puede volver, vía raw_event_id, hasta este archivo exacto.

WebhookRawOLTPETLMétricaTablero
3
Capa OLTP · schema public

El evento se descompone en entidades

Recién ahora se interpreta el JSON. El IngestionService extrae cada parte a su tabla, con las claves de cruce que entretejen todo el modelo.

El payload, repartido en tablas
INSERT INTO sales (transaction_id, total, currency, agency_name, betterez_status, sale_type) VALUES ('TX-9F3A21', 45000, 'ARS', 'Andesmar Mendoza Centro', 'paid', 'sale'); INSERT INTO tickets (ticket_number, sale_id, origin, destination, travel_date, status) VALUES ('BR-7781234', «sale.id», 'Mendoza', 'Buenos Aires', '2026-06-10', 'paid'); INSERT INTO payments(sale_id, type, amount) VALUES («sale.id», 'cash', 45000);

1 venta → N boletos

Acá el boleto es uno. Si fuera ida y vuelta, serían dos filas en tickets bajo la misma sale_id.

Copia fiel

No se "mejora" nada: betterez_status='paid' se guarda como vino. Las reglas de negocio llegan en la próxima capa.

Claves de cruce

TX-9F3A21 y BR-7781234 son los hilos que permitirán, luego, unir esta venta con su manifiesto.

En este punto el dato ya es consultable y fiel, pero todavía no es "negocio": public.sales mezcla ventas, reembolsos e ítems. Falta curarlo. Eso hace el ETL.
WebhookRawOLTPETLMétricaTablero
4
Capa BI · bi_rebuild · ETL incremental

Se reconstruye el hecho

Dos veces al día, un job recorre los boletos cambiados recientemente y reconstruye su fila en fact_ticket, aplicando reglas de negocio y uniendo dimensiones.

El patrón watermark + upsert (simplificado)
-- 1. ventana de reproceso (lookback ~2 días) WHERE t.updated_at >= last_run - interval '2 days' -- 2. reglas de negocio sobre el estado (lower(t.status) = 'paid') AS is_paid, (lower(t.status) = 'cancelled') AS is_cancelled, -- 3. fecha de venta en hora Argentina (s.sale_date AT TIME ZONE 'America/Argentina/Buenos_Aires')::date, -- 4. match con el manifiesto por ticket_number LEFT JOIN manifest_entries me ON me.ticket_number = t.ticket_number

Lookback, no todo

El ETL no reconstruye 1,1 M de boletos cada vez: solo los que cambiaron en la ventana. Eficiente y siempre al día.

Reglas, una sola vez

"Pagado" = status='paid' se decide acá, no en cada reporte. Todos los tableros heredan la misma definición.

Se enriquece con dimensiones

La fila se une a dim_agency (etiqueta canónica), dim_route, dim_date y, por ticket_number, a su manifest_id.

La fila resultante en bi_rebuild.fact_ticket
ticket_numberis_paidticket_amountcurrencyagency_labelroute_idsale_date
BR-7781234true45000.00ARSAndesmar Mendoza CentroMendoza_Buenos Aires2026-06-03
WebhookRawOLTPETLMétricaTablero
5
Capa BI · definición de KPI

De la fila a la métrica

Un KPI no es una columna: es una agregación con un filtro acordado. Nuestro boleto aporta a dos métricas del tablero de ventas.

Pasajes pagados del día
SELECT count(*) FROM bi_rebuild.fact_ticket WHERE is_paid = true AND currency = 'ARS' AND sale_date = '2026-06-03'; -- nuestro boleto suma +1
Facturación pagada del día
SELECT sum(ticket_amount) FROM bi_rebuild.fact_ticket WHERE is_paid = true AND currency = 'ARS' AND sale_date = '2026-06-03'; -- nuestro boleto suma +$45.000
Los dos filtros que no se negocian: is_paid = true (qué cuenta como venta efectiva) y currency = 'ARS' (no se mezclan monedas porque no se convierten). Omitir cualquiera de los dos produce un número que no se puede comparar.
Para profundizar: el detalle de estados (cancelación parcial, reservas expiradas, comparación con la pantalla de Betterez) vive en Estados de boleto; las monedas y montos, en Datos financieros.
WebhookRawOLTPETLMétricaTablero
6
MicroStrategy · decisión

El número en el tablero

MicroStrategy lee fact_ticket y sus dimensiones, y presenta las métricas cruzadas por los atributos que pide negocio. Nuestro boleto ya es parte de una celda.

Ventas pagadas — 03/06/2026 (ARS)

AgenciaPasajesFacturación
Andesmar Mendoza Centro128$5.760.000
Andesmar Córdoba Terminal96$4.310.000
Andesmar San Juan74$3.180.000

Dentro de esos 128 pasajes y $5.760.000 de Mendoza Centro está, indistinguible y correcto, nuestro BR-7781234.

Atributos = dimensiones

"Por agencia" sale de dim_agency; "por día" de dim_date; "por ruta" de dim_route.

Métricas = medidas + filtro

"Pasajes" = count con is_paid; "Facturación" = sum(ticket_amount).

Mismo dato, una verdad

Finanzas, operaciones y dirección leen esta misma fila: nadie recalcula a su manera.

Sección 09 · La garantía

¿Y si el webhook nunca llega?

Toda la historia anterior asume que el evento llegó. ¿Qué pasa si Betterez no lo entrega? Para eso existe la cuarta capa: la consulta activa que cierra los huecos.

Paso 7 · La red de seguridad entra en acción

Un job diario consulta la API de Betterez (GET /transactions) y compara contra public.sales. Si TX-9F3A21 está en la API pero falta en OLTP, lo reinyecta respetando raw-first: vuelve a guardar el JSON en GCS y reanuda el recorrido desde la parada 2.

El mismo recorrido, otra puerta de entrada

La pieza clave es que el safety-net no es un atajo: reinyecta por el mismo camino (raw-first → OLTP → ETL → métrica). Por eso el boleto recuperado es idéntico a uno que llegó por webhook. La idempotencia evita que se duplique si el webhook aparece tarde.

Trece jobs en Google Cloud vigilan distintos huecos posibles —transacciones, manifiestos, webhooks no entregados, catálogos—. Esa es la diferencia entre "esperamos que llegue todo" y "verificamos que llegó todo".
El detalle completo de las cinco capas de protección, los jobs y la evidencia está en Fiabilidad de captura — la próxima parada del recorrido.

Sección 10 · Cierre

Un hecho, siete paradas, un número confiable

Cada celda del tablero es el final de un viaje como este. Entenderlo es saber por qué se puede confiar en el número — y dónde mirar si algo no cuadra.

ParadaCapaQué garantiza
1 · WebhookCapturaLlega en segundos, firmado
2 · Raw-firstRawOriginal intacto, sin duplicados
3 · OLTPpublicCopia fiel, consultable
4 · ETLbi_rebuildReglas y dimensiones, una sola vez
5 · MétricaBIAgregación con filtro acordado
6 · TableroMSTRUna verdad para todos
7 · Safety-netRecuperaciónLo que falte, se reinyecta
Continuá el recorrido: Fiabilidad de captura → profundiza la parada 7: cómo garantizamos que no se nos escape ninguna venta.

Andesmar API · Recorrido del dato · Junio 2026