import { crmRequest, CrmError } from "./client.ts"; import type { CrmCtx } from "./ctx.ts"; /** El enum de la API. La cita en espera es `open`, completada `won`, cancelada `lost`. */ export type CrmOppStatus = "open" | "won" | "lost" | "abandoned"; export interface CrmOpportunity { id: string; name?: string; status?: CrmOppStatus; monetaryValue?: number; pipelineId?: string; pipelineStageId?: string; contactId?: string; } /** Estado de la cita en AgendaMax → estado de la oportunidad en el CRM. */ export function estadoOportunidad(estadoCita: string): CrmOppStatus { switch (estadoCita) { case "completed": return "won"; case "cancelled": case "no_show": // Una cita a la que no vino la clienta tampoco produjo ingreso: para el // embudo del CRM es una pérdida. El matiz de POR QUÉ se perdió (no vino, // canceló ella, canceló el spa) vive en AgendaMax, que sí lo distingue. return "lost"; default: return "open"; } } /** * El nombre de la oportunidad, con el formato acordado: SERVICIO + NOMBRE. * Se recorta a 255 porque el CRM no documenta el límite y un nombre largo * es la clase de cosa que falla en producción y no en pruebas. */ export function nombreOportunidad(servicio: string, cliente: string): string { return `${servicio} — ${cliente}`.slice(0, 255); } export interface EtapasPipeline { pipelineId: string; open?: string | null; won?: string | null; lost?: string | null; } function etapaPara(estado: CrmOppStatus, etapas: EtapasPipeline): string | null { if (estado === "won") return etapas.won ?? null; if (estado === "lost") return etapas.lost ?? null; return etapas.open ?? null; } export async function obtenerOportunidad( ctx: CrmCtx, id: string ): Promise { try { const r = await crmRequest("GET", `/opportunities/${id}`, { token: ctx.token }); return r?.opportunity ?? null; } catch (e) { if (e instanceof CrmError && e.status === 404) return null; throw e; } } /** Las oportunidades de un contacto. `GET /contacts/{id}/opportunities` NO existe (404). */ export async function oportunidadesDeContacto( ctx: CrmCtx, contactId: string ): Promise { const r = await crmRequest("GET", "/opportunities/search", { token: ctx.token, query: { location_id: ctx.locationId, contact_id: contactId, limit: 20 }, }); return r?.opportunities ?? []; } /** * Cambia el estado y la etapa. * * Tres cosas medidas gobiernan esta función, y las tres son contraintuitivas: * * 1. Son **dos llamadas**, no una: `PUT /opportunities/{id}/status` rechaza * `pipelineStageId` con `422 property pipelineStageId should not exist`. * 2. **El orden importa**: el `/status` mueve la etapa por su cuenta, así que * va primero y la etapa deseada se escribe después. Al revés, el `/status` * pisa la etapa recién puesta (medido: de «Ganado» a «Cotización Aceptada»). * 3. En `won`, la subcuenta acaba imponiendo **su** etapa igualmente: se probó * reescribir y releer tres veces y el CRM la devuelve a «Cotización * Aceptada» de forma asíncrona, después de que la relectura ya confirmó la * nuestra. Hay una regla del lado del CRM que gobierna eso, y pelearse con * ella sería un bucle que nunca gana. En `lost` sí respeta «Perdido». * * Se escribe la etapa una vez y no se insiste. Lo que el negocio pidió mapear * es el **estado** —`open` / `won` / `lost`—, y ese sí queda estable y * verificado; la etapa es presentación y la manda el CRM. */ async function aplicarEstado( ctx: CrmCtx, id: string, estado: CrmOppStatus, etapas: EtapasPipeline ): Promise { await crmRequest("PUT", `/opportunities/${id}/status`, { token: ctx.token, body: { status: estado }, }); const etapa = etapaPara(estado, etapas); if (!etapa) return; await crmRequest("PUT", `/opportunities/${id}`, { token: ctx.token, body: { pipelineId: etapas.pipelineId, pipelineStageId: etapa }, }); } export interface ResultadoOportunidad { opportunity: CrmOpportunity; via: "creada" | "reciclada" | "actualizada"; } /** * Proyecta una cita al CRM como oportunidad. * * Dos caminos, y la plataforma elige sola sin configuración: * * 1. **Crear.** Si la subcuenta permite duplicados, cada cita estrena su * oportunidad — que es el modelo pedido. * 2. **Reciclar.** Si responde `400 OPPORTUNITY_NO_DUPLICATE`, el CRM entrega * en `meta.existingId` la que ya existe, y se le pone el nombre, el importe * y el estado de esta cita. * * El camino 2 es el que corre hoy en Yola: la subcuenta tiene * `allowDuplicateOpportunity: false`. Ahí la oportunidad representa *la cita * vigente de la clienta*, y el histórico completo vive en AgendaMax. Si alguien * activa el ajuste en el CRM, esta misma función pasa al camino 1 sin cambios. */ export async function upsertOportunidad( ctx: CrmCtx, args: { contactId: string; nombre: string; importe: number; estado: CrmOppStatus; etapas: EtapasPipeline; /** Si ya la teníamos anclada, se actualiza directamente. */ oportunidadId?: string | null; } ): Promise { const { contactId, nombre, importe, estado, etapas } = args; // Ya anclada: actualizar en sitio. if (args.oportunidadId) { const existente = await obtenerOportunidad(ctx, args.oportunidadId); if (existente) { await crmRequest("PUT", `/opportunities/${args.oportunidadId}`, { token: ctx.token, body: { pipelineId: etapas.pipelineId, name: nombre, monetaryValue: importe }, }); await aplicarEstado(ctx, args.oportunidadId, estado, etapas); const releida = await obtenerOportunidad(ctx, args.oportunidadId); return { opportunity: releida ?? existente, via: "actualizada" }; } // El id guardado ya no resuelve; se sigue por el camino normal. } const body: Record = { pipelineId: etapas.pipelineId, locationId: ctx.locationId, name: nombre, status: estado, contactId, monetaryValue: importe, }; const etapa = etapaPara(estado, etapas); if (etapa) body.pipelineStageId = etapa; try { const r = await crmRequest("POST", "/opportunities/", { token: ctx.token, body }); const creada = r?.opportunity; // Se RELEE antes de dar el id por bueno: la respuesta de creación de esta // API refleja lo que mandaste, no necesariamente lo que persistió. const releida = creada?.id ? await obtenerOportunidad(ctx, creada.id) : null; return { opportunity: releida ?? creada, via: "creada" }; } catch (e) { if (e instanceof CrmError && e.status === 400) { const cuerpo = e.body as any; const existenteId = cuerpo?.meta?.existingId ?? (cuerpo?.code === "OPPORTUNITY_NO_DUPLICATE" ? cuerpo?.meta?.id : null); let id: string | null = existenteId ?? null; if (!id) { // El 400 no trajo el id: se busca. Es el tercer mecanismo, el más débil, // pero aquí la llave natural (contacto + subcuenta) es exacta. const previas = await oportunidadesDeContacto(ctx, contactId); id = previas[0]?.id ?? null; } if (id) { await crmRequest("PUT", `/opportunities/${id}`, { token: ctx.token, body: { pipelineId: etapas.pipelineId, name: nombre, monetaryValue: importe }, }); await aplicarEstado(ctx, id, estado, etapas); const releida = await obtenerOportunidad(ctx, id); if (releida) return { opportunity: releida, via: "reciclada" }; } } throw e; } }