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, commitcec43d8— 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 empresa — convemarco 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 esconvemarco, y el CRM nativo leeleadsdirecto desde el proyecto de la app (functions/src/crm/index.ts:6-7).- Un trigger
onCreatesolo puede escribir en su propio proyecto sin meter credenciales cruzadas en producción. - Ya existía
scripts/migrate-leads-to-convemarco.pyen 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 esnextActionType, y es el único de los dos que está enWRITABLE_LEAD_FIELDS.nextActionno existe. ensureLeadexigía unStaffIdentityy 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
convemarcoy avisar amarko-docspara 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:
| Capa | Dónde | Qué hace |
|---|---|---|
| 1 | registerCustomer | Crea el lead provisional sinrut-<slug del email> con source: signup-web, stage: sql, pendingRut: true y próxima acción agendada |
| 2 | finalizeCompanyCreation | Fusiona ese provisional en el ID canónico (el RUT) y promueve a trialing. Es la única que conoce el userId |
| 3 | onCreateCompanies | Red de seguridad: cualquier empresa que nazca por otra vía queda igual con ficha |
Archivos
| Archivo | Cambio |
|---|---|
functions/src/lib/date.utils.ts | nextBusinessDayAtChile() + helpers de huso horario. Sin feriados: no hay tabla en el repo |
functions/src/crm/models.ts | DEFAULT_OWNER_EMAIL, provisionalIdFromEmail, INTERNAL_EMAIL_DOMAINS, isInternalEmail, isExcludedRut |
functions/src/crm/lib/ensure-lead.ts | LeadActor/systemActor (actor sin sesión), doNotContact en el seed, y promoteLeadToTrialing |
functions/src/crm/lib/signup-lead.ts | nuevo — capa 1 y leadIdForAccount |
functions/src/crm/lib/reconcile-lead.ts | nuevo — la fusión provisional → RUT |
functions/src/crm/lib/company-lead.ts | nuevo — canonicalLeadId y linkCompanyToLead, el puente que usan las capas 2 y 3 |
functions/src/notifications/registerCustomer.function.ts | engancha la capa 1 |
functions/src/admin/companies.ts | engancha las capas 2 y 3 |
functions/src/crm/lib/leads.check.ts | 28 verificaciones puras (npm run check:crm) |
functions/src/crm/lib/leads-flow.check.ts | 41 verificaciones contra el emulador (npm run check:crm:emulator) |
scripts/backfill-leads-from-companies.py | backfill, dry-run por defecto |
firebase.emulator-crm.json | config del emulador en puerto 8099 |
.gitignore | ignora firestore-debug.log |
Decisiones de diseño que hay que conocer antes de tocar esto
stage: sqlpara el registro web, nonew. Ennewviven los ~400 prospectos fríos de Compra Ágil y el registro volvía a ser invisible ahí dentro.sqlestá enLIVE_STAGES, así que aparece en las vistas de cuentas vivas.- Promoción desde
lost, sí; desdewon, 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 enfinalizeCompanyCreationes lo que disparaonCreateCompanies, así que las capas 2 y 3 corren en paralelo sobre el mismo lead. El evento se escribe enevents/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
sourcegenérico (companies,company-sync) cede ante el del provisional (signup-web). - Con
activeCompanyno 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}.leadIdno es un dato confiable.firestore.rules:67daallow 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.leadIdForAccountexige que el lead sea provisional y que suaccountUidapunte de vuelta a ese uid —leadsesallow write: if false, así queaccountUidsolo 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 conmergedIntoo 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:crm→ 28/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, DVK, sin RUT), y los filtros de internas.npm --prefix functions run check:crm:emulator→ 41/41. Los tres escenarios Gherkin del ticket, más idempotencia, preservación del trabajo del comercial, propagación dedoNotContacty el vector delleadIdfalsificado.
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
- 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. - Prueba de alta real en dev. No se alcanzó a hacer. Ojo:
onCreateCompaniesmanda 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. - Deploy a prod. Sin empezar.
- Backfill en producción. Verificado en dry-run, nunca corrido con
--apply. Escribe 39 leads en el tablero del comercial. - Migrar
crm-sync.pyy la miniappcrmaconvemarco. Es del harnessmarko-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
orgCodequeda vacío. El documento de la empresa guardarutcon puntos ("78.197.934-1") yfunctions/src/lib/getOrgCodeFromRut.ts:33haceWHERE Rut = @rutsin 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.planIdno lo setea nadie enfinalizeCompanyCreation.
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 quefirebaseresuelva y el script corra con Node 20. - El emulador necesita el JDK de Homebrew (
/opt/homebrew/opt/openjdk@17); eljavade macOS es un stub. Y el puerto 8080 lo ocupa Docker, de ahí el 8099 defirebase.emulator-crm.json. - El
--configdel emulador tiene que estar dentro del directorio del proyecto, yemulators:execcorre 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.comyfirebase-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 deaccountsque 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.