/** * Atomic Lead-to-Tenant conversion service (Marketing-funnel Phase 2, * Phase 05 #79). * * `convertLeadToTenant` runs inside a single Prisma `$transaction` so the * following five effects either all commit or all roll back together: * * 1. New Tenant row (via direct prisma.create — we cannot reuse the * higher-level `createTenant` helper because it runs outside a * transaction. Slug + inboundAddress allocation is delegated to the * pre-computed values passed in by the caller). * 2. Lead row update: `status` advances to `trial` (default) or * `customer` (when caller requests it), `convertedToTenantId` + * `convertedAt` populated. * 3. `LeadEvent` of type `converted` appended for the activity * timeline. * 4. `TenantAcquisition` row inserted with first-touch attribution * snapshot copied from the Lead. * 5. `OutboxEvent` published — `marketing.lead.convertedtotrial` or * `marketing.lead.convertedtocustomer` — picked up downstream by * the marketing-outbound worker handler to fan out webhooks (Slack, * Discord, Mailchimp, HubSpot, Brevo, custom). * * Idempotency: the outbox dispatchKey is `lead-convert:` so a * re-run after partial failure produces the same outbox row (publishOutboxEvent * returns the existing event on unique-constraint conflict). The Lead's * `convertedToTenantId` check up-front turns repeated conversion attempts * into no-ops, returning the already-created Tenant + Lead. */ import { Prisma } from "@toolctrl/db/prisma-browser"; import { publishOutboxEvent, type OutboxPublisherTransaction } from "@toolctrl/db/outbox"; import { db } from "~/utils/db.server"; export type ConvertedStatus = "trial" | "customer"; export type ConversionPath = "self-service" | "admin-override" | "api"; export interface ConvertLeadInput { leadId: string; /** Lead-status target after conversion. Defaults to `trial`. */ targetStatus?: ConvertedStatus; /** Free-text path label for the TenantAcquisition row. */ conversionPath?: ConversionPath; /** Pre-computed tenant params — caller resolves slug and inbound address. */ tenant: { name: string; slug: string; icon?: string | null; /** Pre-computed inbound address (e.g. via `getAvailableTenantInboundAddress`). */ inboundAddress: string; }; /** Optional acting admin user id when conversionPath === "admin-override". */ actorId?: string | null; /** * Optional plan-id (SubscriptionProduct.id) carried through to the * webhook payload — does NOT enrol the tenant into a subscription. The * existing `addToTrialOrFreePlan` post-register flow handles that * separately so we don't accidentally double-bill. */ planId?: string | null; } export interface ConvertLeadResult { tenantId: string; leadId: string; status: ConvertedStatus; alreadyConverted: boolean; /** Outbox event id (useful for tests). */ outboxEventId: string | null; } export interface RecordExistingTenantLeadConversionInput { leadId: string; tenantId: string; targetStatus?: ConvertedStatus; conversionPath?: ConversionPath; actorId?: string | null; planId?: string | null; } export class LeadNotFoundError extends Error { override readonly name = "LeadNotFoundError"; constructor(leadId: string) { super(`Lead ${leadId} not found`); } } export class LeadAlreadyConvertedError extends Error { override readonly name = "LeadAlreadyConvertedError"; constructor( leadId: string, public readonly convertedToTenantId: string ) { super(`Lead ${leadId} is already converted.`); } } export class LeadConversionDisabledError extends Error { override readonly name = "LeadConversionDisabledError"; constructor() { super("Lead conversion is disabled (LEAD_CONVERSION_ENABLED=false)."); } } export const MARKETING_LEAD_EVENT_PREFIX = "marketing.lead"; export function buildLeadConversionEventType(status: ConvertedStatus): string { return status === "customer" ? `${MARKETING_LEAD_EVENT_PREFIX}.convertedtocustomer` : `${MARKETING_LEAD_EVENT_PREFIX}.convertedtotrial`; } export async function recordExistingTenantLeadConversion(input: RecordExistingTenantLeadConversionInput): Promise { if (process.env.LEAD_CONVERSION_ENABLED === "false") { throw new LeadConversionDisabledError(); } const status: ConvertedStatus = input.targetStatus ?? "trial"; const conversionPath: ConversionPath = input.conversionPath ?? "self-service"; return await db.$transaction(async (tx) => { const lead = await tx.lead.findUnique({ where: { id: input.leadId }, select: { id: true, email: true, firstName: true, lastName: true, company: true, source: true, utmSource: true, utmMedium: true, utmCampaign: true, utmTerm: true, utmContent: true, gclid: true, fbclid: true, referrer: true, landingPage: true, firstSeenAt: true, convertedToTenantId: true, }, }); if (!lead) throw new LeadNotFoundError(input.leadId); if (lead.convertedToTenantId) { if (lead.convertedToTenantId !== input.tenantId) { throw new LeadAlreadyConvertedError(lead.id, lead.convertedToTenantId); } return { tenantId: input.tenantId, leadId: lead.id, status, alreadyConverted: true, outboxEventId: null, }; } const now = new Date(); await tx.lead.update({ where: { id: lead.id }, data: { status, convertedToTenantId: input.tenantId, convertedAt: now, }, }); await tx.leadEvent.create({ data: { leadId: lead.id, type: "converted", actorId: input.actorId ?? null, payload: { tenantId: input.tenantId, conversionPath, targetStatus: status, planId: input.planId ?? null, } as Prisma.InputJsonValue, }, }); await tx.tenantAcquisition.upsert({ where: { tenantId: input.tenantId }, create: { tenantId: input.tenantId, leadId: lead.id, source: lead.source ?? conversionPath, utmSource: lead.utmSource, utmMedium: lead.utmMedium, utmCampaign: lead.utmCampaign, utmTerm: lead.utmTerm, utmContent: lead.utmContent, gclid: lead.gclid, fbclid: lead.fbclid, referrer: lead.referrer, landingPage: lead.landingPage, capturedAt: lead.firstSeenAt, convertedAt: now, conversionPath, customFields: input.planId != null ? ({ planId: input.planId } as Prisma.InputJsonValue) : Prisma.JsonNull, }, update: { leadId: lead.id, source: lead.source ?? conversionPath, utmSource: lead.utmSource, utmMedium: lead.utmMedium, utmCampaign: lead.utmCampaign, utmTerm: lead.utmTerm, utmContent: lead.utmContent, gclid: lead.gclid, fbclid: lead.fbclid, referrer: lead.referrer, landingPage: lead.landingPage, capturedAt: lead.firstSeenAt, convertedAt: now, conversionPath, customFields: input.planId != null ? ({ planId: input.planId } as Prisma.InputJsonValue) : Prisma.JsonNull, }, }); const outboxEvent = await publishOutboxEvent(tx as unknown as OutboxPublisherTransaction, { tenantId: input.tenantId, type: buildLeadConversionEventType(status), payload: { leadId: lead.id, tenantId: input.tenantId, targetStatus: status, conversionPath, planId: input.planId ?? null, email: lead.email, firstName: lead.firstName, lastName: lead.lastName, company: lead.company, utm: { source: lead.utmSource, medium: lead.utmMedium, campaign: lead.utmCampaign, term: lead.utmTerm, content: lead.utmContent, }, referrer: lead.referrer, landingPage: lead.landingPage, convertedAt: now.toISOString(), } as Prisma.InputJsonValue, dispatchKey: `lead-convert:${lead.id}`, }); return { tenantId: input.tenantId, leadId: lead.id, status, alreadyConverted: false, outboxEventId: outboxEvent.id, }; }); } export async function convertLeadToTenant(input: ConvertLeadInput): Promise { if (process.env.LEAD_CONVERSION_ENABLED === "false") { throw new LeadConversionDisabledError(); } const status: ConvertedStatus = input.targetStatus ?? "trial"; const conversionPath: ConversionPath = input.conversionPath ?? "self-service"; return await db.$transaction(async (tx) => { const lead = await tx.lead.findUnique({ where: { id: input.leadId }, select: { id: true, email: true, firstName: true, lastName: true, company: true, source: true, utmSource: true, utmMedium: true, utmCampaign: true, utmTerm: true, utmContent: true, gclid: true, fbclid: true, referrer: true, landingPage: true, firstSeenAt: true, status: true, convertedToTenantId: true, }, }); if (!lead) throw new LeadNotFoundError(input.leadId); // Idempotent re-run: already-converted leads return existing tenant. if (lead.convertedToTenantId) { return { tenantId: lead.convertedToTenantId, leadId: lead.id, status, alreadyConverted: true, outboxEventId: null, }; } const tenant = await tx.tenant.create({ data: { name: input.tenant.name, slug: input.tenant.slug, icon: input.tenant.icon ?? undefined, inboundAddresses: { create: [{ address: input.tenant.inboundAddress }], }, }, select: { id: true }, }); const now = new Date(); await tx.lead.update({ where: { id: lead.id }, data: { status, convertedToTenantId: tenant.id, convertedAt: now, }, }); await tx.leadEvent.create({ data: { leadId: lead.id, type: "converted", actorId: input.actorId ?? null, payload: { tenantId: tenant.id, tenantSlug: input.tenant.slug, conversionPath, targetStatus: status, planId: input.planId ?? null, } as Prisma.InputJsonValue, }, }); await tx.tenantAcquisition.create({ data: { tenantId: tenant.id, leadId: lead.id, source: lead.source ?? conversionPath, utmSource: lead.utmSource, utmMedium: lead.utmMedium, utmCampaign: lead.utmCampaign, utmTerm: lead.utmTerm, utmContent: lead.utmContent, gclid: lead.gclid, fbclid: lead.fbclid, referrer: lead.referrer, landingPage: lead.landingPage, capturedAt: lead.firstSeenAt, convertedAt: now, conversionPath, customFields: input.planId != null ? ({ planId: input.planId } as Prisma.InputJsonValue) : Prisma.JsonNull, }, }); const eventType = buildLeadConversionEventType(status); const outboxEvent = await publishOutboxEvent(tx as unknown as OutboxPublisherTransaction, { tenantId: tenant.id, type: eventType, payload: { leadId: lead.id, tenantId: tenant.id, targetStatus: status, conversionPath, planId: input.planId ?? null, email: lead.email, firstName: lead.firstName, lastName: lead.lastName, company: lead.company, utm: { source: lead.utmSource, medium: lead.utmMedium, campaign: lead.utmCampaign, term: lead.utmTerm, content: lead.utmContent, }, referrer: lead.referrer, landingPage: lead.landingPage, convertedAt: now.toISOString(), } as Prisma.InputJsonValue, dispatchKey: `lead-convert:${lead.id}`, }); return { tenantId: tenant.id, leadId: lead.id, status, alreadyConverted: false, outboxEventId: outboxEvent.id, }; }); }