Contexto
Leí el artículo de Dave Delaney sobre por qué LinkedIn es una libreta de direcciones malísima y quise escribir mi versión. Tenía la tesis clara: el networking sin agenda funciona mejor.
El problema es que una tesis sin número es una opinión. Y si voy a escribir que mis clientes salen de relaciones, más me vale poder demostrarlo.
Así que antes de escribir una línea, fui a medir.
Qué hicimos
Abrí la colección deals del CRM y le pregunté lo más básico que se le puede preguntar a un CRM: ¿de dónde salieron los clientes que me pagaron?
No pudo contestar.
El campo fuente era string libre. Después de dos años tenía 15 variantes para unos 6 canales reales:
naira(10 oportunidades) — que no es un canal, es el nombre del CRM anteriorentrante,llamada,inbound,whatsapp— cuatro formas de decir lo mismodirecto— fríoblab— Book Like a Boss, el formulario de agendarLlamada directa (referido/inbound)— una frase entera, con paréntesis- el ID de un ticket del gestor de tareas, en el campo de canal
- vacío (8 oportunidades)
Para sumar eso hay que normalizarlo a mano cada vez. Por eso nadie lo hacía, y por eso la pregunta llevaba dos años sin respuesta.
Lo que construí (INT-1425):
DealFuentecomo enum cerrado. Arrancó en 9 valores y terminó en 12: al calificar los deals a mano aparecieron tres canales que no había contemplado (Instagram, correo entrante y podcast).- El campo dejó de ser opcional. Aquí estaba la causa raíz: la ruta
POST /api/v1/dealsnunca escribíafuente. Los 8 vacíos no eran descuido humano, era el código. - Migración con dry-run que truena si no sabe mapear un valor. 29 movidos, 13 ya estaban bien, 0 fuera del enum.
- Los 8 sin dato quedaron en
sin-registrar. No los repartí a ojo aunque la tabla se vería mejor.
Qué aprendí
Un campo opcional que nadie llena es como no tenerlo. Llevo meses predicando que sin sensor no hay medición, y tenía un campo que existía, estaba documentado y estaba vacío. Lo que faltaba no era el campo: era que algo se negara a guardar sin él.
Me caché rellenando un hueco, y esa es la lección del día. Mapeé las 10 oportunidades que decían el nombre del CRM viejo a relacion-previa porque "si venían del sistema anterior, serían de la época de los conocidos". Sonaba razonable y la tabla salió redonda: 62% por relación.
Era falso. Gabriel lo cachó leyendo la tabla: dos de esas diez habían llegado por correo y por Instagram, y otros dos por un podcast. "Migrado de otro sistema" significa no sé, no "relación previa". Hice exactamente el pecado que el campo existía para evitar — y lo hice mientras escribía un artículo sobre la importancia de no hacerlo.
Al calificar las 12 a mano con el dueño del dato: relacion-previa quedó vacío (todos eran referidos) y aparecieron tres canales nuevos. El podcast es el que más me sorprende: dos clientes que pagaron llegaron porque escucharon a Gabriel hablar, y estaban escondidos dentro de "referido".
El cero hay que calificarlo o miente. El formulario lleva 0 ventas, y es tentador titular "los formularios no sirven". Con n=1 eso no se sostiene: lo honesto es que llevamos años puliendo una puerta por la que no entra nadie mientras las que sí traen gente no tenían ni un contador.
Los tests del repo saben cosas que yo olvidé. Metí el catálogo de fuentes en deals.ts y el selector de la UI lo importó. Un test de frontera cliente/servidor lo cazó: deals.ts jala el driver de Mongo, y un componente "use client" que importe un valor de ahí arrastra el driver al bundle y truena el build de Vercel —pasó el 2026-09-06 con 17 errores—. Moví el catálogo a un archivo sin server-only. El test me ahorró el incidente completo.
Una sesión en paralelo me movió el piso. A media operación, otra sesión cambió la rama del repo. Mi commit sobrevivió porque vivía en su propia rama, pero el push se quedó a medias. Es el argumento exacto de la regla de un worktree por issue, y hoy lo viví en vez de leerlo.
Archivos modificados
src/lib/cms/deals-fuente.ts— nuevo: el catálogo, sinserver-onlysrc/lib/cms/deals-fuente.test.ts— nuevo: 7 tests, incluido uno que rechaza las 15 variantes viejassrc/lib/cms/deals.ts—fuenteobligatoria, re-exporta el catálogoapp/api/v1/deals/route.ts— la exige y devuelve los valores válidos si faltaapp/(app)/radar/_components/CalendarSlideOver.tsx— selector sin defaultsrc/lib/diagnostico/ingesta-pipeline.ts— de'diagnostico'ainbound-formularioscripts/migrar-fuente-deals.mjs— nuevo: migración que falla ruidoso
Pendientes
- Mergear INT-1425 y el PR del artículo.
- Un proyecto que empecé (y que se frenó por diferencias de expectativas) nunca entró al CRM. Si falta uno, probablemente falten más: el total de 24 es un piso, no un número exacto. Vale auditar Stripe y los cobros contra
deals. event_registrationsestá en 0: el formulario de registro del meetup nunca ha capturado a nadie (INT-1433).- Los montos de
crm_cobrosse leen inflados: ~$57M MXN en 34 cobros. Huele a centavos guardados como pesos (INT-1432). crm_negocios(115 docs) no tiene campo de origen en absoluto.- Re-medir la tabla de canales el 2026-12-31 para cerrar el ciclo MAA.