Saltar al contenido principal

El registro web no crea el lead en el CRM (leads)

Fecha: 2026-08-22 · Ticket: MRKO-320 (CRM comercial nativo) · Origen: traspaso del harness marko-docs, archivo .idlework/entrantes/2026-08-21-el-registro-web-no-crea-el-lead-en-el-crm-leads.md

⚠️ Antes que nada: dónde está el código

El trabajo NO está en la rama donde vive esta bitácora.

  • Código del fix: rama bugfix/MRKO-320, commit cec43d8 — 14 archivos, 1647 inserciones. Completo, compila, con tests pasando.
  • Esta bitácora: commiteada en feature/MRKO-327, porque a mitad del trabajo el workspace cambió de rama por fuera de la sesión (ver «Incidente» más abajo) y las instrucciones de cierre prohibían cambiar de rama.

Para retomar: git checkout bugfix/MRKO-320.

Qué se pidió

Cuando alguien se registra desde la web y se le crea la empresa con trial, no queda lead en la colección leads, así que el comercial no lo ve en el CRM. El traspaso pedía leer el ticket, verificar en el código los puntos marcados como «a decidir» —sobre todo en qué proyecto Firestore debe escribirse el lead— y proponer un plan antes de tocar código.

El caso que lo detonó: TIMBRESYSELLOS SPA (RUT 78.197.934-1, convemarco/companies/2052494), registrada el 2026-08-21, con trial corriendo y sin lead en ninguno de los dos proyectos.

Verificación: qué se encontró (y en qué se equivocaba el ticket)

1. El bug es mucho más grande que el caso reportado

Cruzando por REST las 46 empresas de convemarco contra leads: 42 no tenían lead. Y el gap era idéntico en marko-devenv (4 de 46 en ambos). No es que TIMBRESYSELLOS se cayera entre dos sistemas: el alta de empresa nunca creó un lead, nunca.

Los 4 leads de clientes que existían eran byte a byte iguales en los dos proyectos, con createdBy: crm-sync.py y fetch-leads-compra-agil.py — los scripts Python de marko-docs escriben en ambos. Ninguno tenía companyId. Los 433/444 documentos de leads son casi todos prospectos fríos de Compra Ágil, no clientes.

2. En qué proyecto va el lead (el punto 1 del ticket)

Decisión: en el proyecto donde nace la empresaconvemarco en prod, marko-devenv en dev.

La pregunta «cuál leads es la productiva» no se decide por contenido, porque ninguna lo era. Lo que decide:

  • src/environments/environment.prod.ts:7 → el admin Angular de producción es convemarco, y el CRM nativo lee leads directo desde el proyecto de la app (functions/src/crm/index.ts:6-7).
  • Un trigger onCreate solo puede escribir en su propio proyecto sin meter credenciales cruzadas en producción.
  • Ya existía scripts/migrate-leads-to-convemarco.py en este repo, con el mismo razonamiento en su docblock.

Consecuencia pendiente: crm-sync.py y la miniapp crm (harness marko-docs) todavía leen marko-devenv/leads. Mientras no se migren, el lead existirá y el comercial no lo verá.

3. ensureLead NO cumplía el escenario 2 del ticket (punto 2)

El traspaso afirmaba que ensureLead ya tenía la lógica de idempotencia y que solo había que «verificar que la respeta». No la tenía: si el documento existía hacía return { ref, created: false } sin tocar un solo campo. Preservaba el historial, sí, pero no promovía a trialing ni enlazaba companyId. Hubo que escribir promoteLeadToTrialing.

4. EXCLUDED_RUTS ya existía y nadie la usaba (punto 3)

Estaba en functions/src/crm/models.ts (DIP, MAIMAG, MARKO TEST) sin un solo consumidor. El filtro se resolvió consumiéndola, más un filtro nuevo por dominio de correo interno (marko.cl, maimag.cl, simplit-solutions.com) para el caso del registro, donde todavía no hay RUT con el que filtrar.

5. Otros puntos de entrada (punto 4)

En este repo el único camino a companies es finalizeCompanyCreation (llamada desde company-form.component.ts:404) más provisionCompanyCharts.ts:113. No existe el CompanyRegistrationComponent que menciona un comentario en register.component.ts:113: es un comentario obsoleto. Enganchar en el trigger onCreateCompanies cubre por construcción cualquier landing externa, siempre que escriba en Firestore.

6. Divergencias del ticket con el schema real

  • El ticket pedía nextAction (texto). El campo real es nextActionType, y es el único de los dos que está en WRITABLE_LEAD_FIELDS. nextAction no existe.
  • ensureLead exigía un StaffIdentity y un trigger no tiene sesión → hubo que admitir un actor de sistema.
  • No existía helper de día hábil en date.utils.ts.

La decisión que cambió el diseño

Al preguntar qué source usar (el trigger no puede distinguir un registro web de un alta manual del admin, porque ambos pasan por el mismo formulario), Christian corrigió el enfoque de raíz:

«Di un lead crea una cuenta pero no crea la empresa igual es lead, se debe crear desde antes y luego complementarlo con la empresa»

Es decir: el lead nace en el registro de la cuenta, no en el alta de la empresa. Quien crea una cuenta y nunca llega a crear su empresa es igual de lead —de hecho es el que más necesita que alguien lo llame— y ése era el caso que se perdía completo.

Eso resolvió sola la pregunta del source, sin tocar el formulario: signup-web si el lead nació del registro, companies si apareció por el trigger.

Como en el registro no hay RUT (la web solo pide nombre y correo, register.component.ts:88-93), el lead nace provisional y después se fusiona.

Las otras dos decisiones tomadas por Christian:

  • Destino: escribir en convemarco y avisar a marko-docs para que migren el sync.
  • Backfill: las 42 empresas, con stage según paymentStatus.

Qué se implementó (commit cec43d8 en bugfix/MRKO-320)

Tres capas, todas idempotentes:

CapaDóndeQué hace
1registerCustomerCrea el lead provisional sinrut-<slug del email> con source: signup-web, stage: sql, pendingRut: true y próxima acción agendada
2finalizeCompanyCreationFusiona ese provisional en el ID canónico (el RUT) y promueve a trialing. Es la única que conoce el userId
3onCreateCompaniesRed de seguridad: cualquier empresa que nazca por otra vía queda igual con ficha

Archivos

ArchivoCambio
functions/src/lib/date.utils.tsnextBusinessDayAtChile() + helpers de huso horario. Sin feriados: no hay tabla en el repo
functions/src/crm/models.tsDEFAULT_OWNER_EMAIL, provisionalIdFromEmail, INTERNAL_EMAIL_DOMAINS, isInternalEmail, isExcludedRut
functions/src/crm/lib/ensure-lead.tsLeadActor/systemActor (actor sin sesión), doNotContact en el seed, y promoteLeadToTrialing
functions/src/crm/lib/signup-lead.tsnuevo — capa 1 y leadIdForAccount
functions/src/crm/lib/reconcile-lead.tsnuevo — la fusión provisional → RUT
functions/src/crm/lib/company-lead.tsnuevo — canonicalLeadId y linkCompanyToLead, el puente que usan las capas 2 y 3
functions/src/notifications/registerCustomer.function.tsengancha la capa 1
functions/src/admin/companies.tsengancha las capas 2 y 3
functions/src/crm/lib/leads.check.ts28 verificaciones puras (npm run check:crm)
functions/src/crm/lib/leads-flow.check.ts41 verificaciones contra el emulador (npm run check:crm:emulator)
scripts/backfill-leads-from-companies.pybackfill, dry-run por defecto
firebase.emulator-crm.jsonconfig del emulador en puerto 8099
.gitignoreignora firestore-debug.log

Decisiones de diseño que hay que conocer antes de tocar esto

  • stage: sql para el registro web, no new. En new viven los ~400 prospectos fríos de Compra Ágil y el registro volvía a ser invisible ahí dentro. sql está en LIVE_STAGES, así que aparece en las vistas de cuentas vivas.
  • Promoción desde lost, sí; desde won, no. Una cuenta que acaba de arrancar un trial no está perdida (es el caso de CLINICON). Un cliente pagando no se degrada.
  • Idempotencia por doc ID determinista. El set() de la empresa en finalizeCompanyCreation es lo que dispara onCreateCompanies, así que las capas 2 y 3 corren en paralelo sobre el mismo lead. El evento se escribe en events/trial-started-<companyId>, así que el orden no cambia el resultado. Verificado: tres pasadas dejan un solo evento.
  • El origen específico gana. Si el trigger crea el lead antes de la fusión, el source genérico (companies, company-sync) cede ante el del provisional (signup-web).
  • Con activeCompany no se crea lead. Ese camino es el alta de un usuario para una empresa que ya es cliente (accounts.component.ts); sin el filtro el tablero comercial se llenaría de empleados de clientes.
  • accounts/{uid}.leadId no es un dato confiable. firestore.rules:67 da allow write: if isOwner(userId), así que el usuario controla ese campo. Sin validarlo, quien se registra podría apuntarlo al lead de otra empresa y hacer que su alta se fusionara con él, arrastrándose el dueño y las notas de una cuenta ajena. leadIdForAccount exige que el lead sea provisional y que su accountUid apunte de vuelta a ese uid — leads es allow write: if false, así que accountUid solo lo pudo escribir el Admin SDK. El camino por correo de contacto exige además que la cuenta tenga rol en esa empresa.
  • El provisional no se borra: queda con mergedInto: <rut> como traza. El CRM y el sync deben excluir los leads con mergedInto o mostrarán la cuenta duplicada.

Qué se probó y con qué resultado

Todo verde al momento del commit:

  • npm --prefix functions run build → limpio.
  • npm run lint → el tslint del repo está roto de antes (incompatible con TS 5), pero 0 findings sobre los archivos tocados.
  • npm --prefix functions run check:crm28/28. Cubre el día hábil con y sin horario de verano chileno, altas nocturnas que no corren la fecha, elección del ID (RUT válido, DV malo, DV K, sin RUT), y los filtros de internas.
  • npm --prefix functions run check:crm:emulator41/41. Los tres escenarios Gherkin del ticket, más idempotencia, preservación del trabajo del comercial, propagación de doNotContact y el vector del leadId falsificado.

El check del emulador encontró un bug real que el código puro no mostraba: al fusionar un lead que tenía doNotContact, ensureLead creaba el definitivo con el nextActionAt del seed antes de que se propagara la marca, así que le agendaba una llamada de bienvenida a alguien que había pedido que no lo contactaran. Corregido pasando doNotContact en el seed.

Backfill verificado en dry-run contra producción: 39 a crear, 4 a complementar, 3 internas omitidas (DIP, MAIMAG, MARKO TEST). El dry-run imprime exactamente los campos que escribiría.

Un hallazgo del dry-run: CLINICON está hoy en trial pero su lead quedó en stage: lost por la campaña cold. El backfill lo promueve a trialing. Es la misma cuenta que motivó el CRM (se registró sola, 14 días sin contacto, respondió que no en 45 minutos).

Incidente: el workspace cambió de rama por fuera de la sesión

Después del commit, el workspace pasó de bugfix/MRKO-320 a feature/MRKO-327 sin dejar entrada en el reflog y sin worktrees en juego (git worktree list muestra uno solo). No lo hizo esta sesión. El commit cec43d8 quedó intacto en su rama.

Efecto colateral en dev: se había alcanzado a correr firebase deploy --only functions:registerCustomer,functions:finalizeCompanyCreation,functions:onCreateCompanies --project dev. Como firebase.json tiene predeploy: npm run build, el deploy recompiló desde el src de la rama nueva —sin el fix— y subió eso. Verificado sobre el bundle: companies.js sin linkCompanyToLead, registerCustomer.function.js sin createSignupLead, ensure-lead.js de vuelta a su versión original.

feature/MRKO-327 no toca ninguno de esos dos archivos (diff vacío contra main), así que dev no quedó roto: el deploy fue inocuo e inútil a la vez. Las tres functions de dev tienen hoy el código de main, sin el fix.

Se limpiaron seis artefactos huérfanos que habían quedado en functions/lib/ (.js compilados sin fuente correspondiente, que un deploy futuro habría subido como código muerto).

Qué quedó a medias

  1. Deploy a dev del fix. Pendiente: hay que volver a bugfix/MRKO-320, rebuild y redeploy. El deploy que se hizo no lleva el fix.
  2. Prueba de alta real en dev. No se alcanzó a hacer. Ojo: onCreateCompanies manda un WhatsApp al grupo comercial antes de tocar el lead, así que crear la empresa de prueba dispara un [DEV] Se creó una nueva compañia… a gente real.
  3. Deploy a prod. Sin empezar.
  4. Backfill en producción. Verificado en dry-run, nunca corrido con --apply. Escribe 39 leads en el tablero del comercial.
  5. Migrar crm-sync.py y la miniapp crm a convemarco. Es del harness marko-docs. El aviso ya se dejó allá (ver abajo).

Siguiente paso concreto

git checkout bugfix/MRKO-320
nvm use 20
npm --prefix functions run check:crm # 28/28
npm --prefix functions run check:crm:emulator # 41/41
firebase deploy --only functions:registerCustomer,functions:finalizeCompanyCreation,functions:onCreateCompanies --project dev
# probar un alta real en dev (dispara WhatsApp [DEV] al grupo comercial)
# luego prod, y al final:
python3 scripts/backfill-leads-from-companies.py # dry-run
python3 scripts/backfill-leads-from-companies.py --apply

bugfix/MRKO-320 fue pusheada a origin en el cierre de esta sesión, así que el trabajo no vive solo en local.

Traspaso a marko-docs: ya hecho, con una salvedad

El aviso quedó en workspace/marko/marko-docs/.idlework/entrantes/2026-08-21-el-lead-del-registro-web-ya-se-crea-falta-apuntar-crm-sync.md.

La API local de IdleWork no tiene endpoint de traspasos en esta versión: POST /conversations devuelve {"error":"Ruta no encontrada."}, igual que /api/conversations, /v1/conversations, /harnesses/<id>/conversations, /handoff, /handoffs y /miniapps. Solo responden GET /harnesses y POST /ask. Por eso el .md se escribió directo en la carpeta de entrada del otro harness, lo que no crea la conversación entrante en la app: hay que abrirlo a mano desde ese harness.

Dos huecos aparte, encontrados y NO tocados

  1. orgCode queda vacío. El documento de la empresa guarda rut con puntos ("78.197.934-1") y functions/src/lib/getOrgCodeFromRut.ts:33 hace WHERE Rut = @rut sin normalizar. Es una hipótesis fundada, no confirmada contra BigQuery (no se quiso gastar en la query: las tablas de Convenio Marco acumulan). Explicaría que TIMBRESYSELLOS quedara sin orgCode aunque en BigQuery figure con 1472360 y 8 OC de Compra Ágil.
  2. planId no lo setea nadie en finalizeCompanyCreation.

Los dos merecen su propio ticket.

Notas de entorno útiles

  • El CLI de firebase no está en el PATH con Node 20 (el que exige el proyecto): vive en ~/.nvm/versions/node/v24.13.1/bin/firebase. Poner el bin de Node 20 primero y el de 24 después hace que firebase resuelva y el script corra con Node 20.
  • El emulador necesita el JDK de Homebrew (/opt/homebrew/opt/openjdk@17); el java de macOS es un stub. Y el puerto 8080 lo ocupa Docker, de ahí el 8099 de firebase.emulator-crm.json.
  • El --config del emulador tiene que estar dentro del directorio del proyecto, y emulators:exec corre el comando con el cwd desde donde se invocó (no la raíz).
  • Firestore por REST necesita las service accounts, no las cuentas humanas (dan 403): marko-cron@convemarco.iam.gserviceaccount.com y firebase-adminsdk-blc2l@marko-devenv.iam.gserviceaccount.com.

Documentación permanente: qué actualizar al mergear el fix

No se tocó la documentación del repo en el cierre, a propósito: el fix vive en otra rama, y describir acá un comportamiento que el código de feature/MRKO-327 no tiene dejaría la doc mintiendo. Al mergear bugfix/MRKO-320, actualizar docs/04-configuracion/12-crm-comercial.md:

  • El alta automática del lead. Hoy el documento no dice de dónde sale un lead nuevo. Agregar el flujo de tres capas: el registro crea el provisional, la empresa lo completa, el trigger es la red de seguridad.
  • mergedInto. Sumarlo como campo del lead y dejar dicho que los listados deben excluir los leads que lo tengan, o la cuenta aparece duplicada. Es el contrato más fácil de olvidar de todo el cambio.
  • accountUid. Campo nuevo: enlaza el lead con la cuenta de accounts que lo originó.

Lo que no hace falta cambiar: la línea 35 ya describe correctamente las cuentas excluidas y los dominios internos (marko.cl, maimag.cl, simplit-solutions.com), que es exactamente lo que implementa isExcludedRut/isInternalEmail. Y la línea 117 ya lista nextActionType con el nombre correcto.

El schema de leads como tal se documenta en el repo marko-docs (knowledge/interno/comercial/conceptos/schema-leads-firestore.md), no acá; los contratos nuevos se le comunicaron en el traspaso.