Renewal scaffold on Closed Won

Drive the in-system renewal contract + draft quote off the Salesforce Closed Won event, instead of waiting for the renewal opportunity to drift in through CRM sync.

PRdealops#5809 Author@mileszim Basemain Files12 +/−+1445 / −7 AreaDealops 2 · renewals Org gateLangchain

Problem

Renewal machinery existed (getContractStateAsOf, resolveEngagementRenewalSeed, reseedRenewalQuote) but nothing proactively scaffolded a renewal on new-business close. The only refresh trigger was quote.primary_set, which skips draft renewal quotes.

Fix

One idempotent orchestrator — ensureRenewalForClosedWonContract — wired to two Closed-Won-driven reactors. Creates a PENDING renewal on new-business close; refreshes the draft quote on expansion close.

Out of scope

Renewal activation (PENDING → ACTIVE and close prior when the renewal itself closes won) is explicitly deferred. The SF query that resolves the renewal opp id ships as a stub, making the create branch a clean no-op until the org-specific SOQL lands.

ACTIVE contract (prior)
Closed Won event / in-flight work
PENDING renewal (new)
Reactor / orchestrator

1. Why this exists

For the Langchain engagement-contract model, when a deal hits Salesforce Closed Won two things have to happen in lockstep:

  1. Write the deal group back to Salesforce — already wired via langchainDealGroupSync.
  2. Keep an in-system renewal in sync — this PR.
Before

Renewal refresh only fired on quote.primary_set, which routes through resolveCanonicalQuote and only overwrites isPrimary quotes. A draft renewal quote is not primary, so it was silently skipped.

Renewal creation only happened reactively, when the renewal opp eventually synced from CRM through LangchainOpportunityEnrichment. Nothing fired on new-business close.

After

Both triggers fan out from the opportunity.updated Closed Won event (plus a second Deal_Group__c reconciliation signal for the renewal-link follow-up). One idempotent orchestrator handles both branches and self-heals on retry.

2. The Closed Won flow, step by step

Step through what happens when a contract head goes Closed Won. State colors follow the legend above.

3. What changes, file by file

FileRoleΔ
EngagementRenewalScaffoldService.ts The orchestrator. One entry point: ensureRenewalForClosedWonContract. Picks create vs refresh, eagerly fetches the renewal opp from SF, attaches it as sequence-1 RENEWAL, creates the draft quote. +439
engagementRenewalScaffoldReactor.ts Listener for opportunity.updated. Sibling to langchainDealGroupSync so renewal-lifecycle retries are independent of the SF writeback. +166
renewalReconcileFromDealGroupReactor.ts Second listener, this one on the new crm.deal_group_updated event. Reconciles when Langchain's SF automation links the renewal opp onto our Deal_Group__c row after the fact. +127
findRenewalOpportunityId.ts SOQL stub reading Opportunity_Replaced_By__c off the closed-won opp. Returns null until org-specific wiring lands → create branch is a clean no-op. +91
EngagementRenewalChainService.ts Splits the existing successor lookup into two: findPendingRenewalSuccessorContract (membership-independent) and findPendingRenewalSuccessorOpp (head-opp keyed). The first one is the self-heal hook. +25/-3
LangchainOpportunityEnrichment.ts The create-renewal enrichment path now skips when a PENDING renewal already exists for the prior — dedupes against the proactive scaffold. +17
processAmpersandWebhook.ts Adds a Deal_Group__c branch: match the row back to our DealGroup via Deal_Group_Id__c, emit crm.deal_group_updated. Also adds a renewalLinkObserved marker on the opportunity patch. +148/-4
eventSchemas.ts Registers the new crm.deal_group_updated event with its payload schema. +18
+ tests Mocha suites for the orchestrator (8 scenarios) and the SOQL stub (4 scenarios). +408

4. How the orchestrator branches

The branch is chosen by a single lookup — findPendingRenewalSuccessorOpp(priorDealGroupId). Returns an opp id → refresh. Returns null → create.

Create branch (no successor yet)
  1. Resolve the renewal SF opp id: prefer the caller-supplied override (Deal_Group__c path), else fall back to findRenewalOpportunityId (SOQL on Opportunity_Replaced_By__c).
  2. Look up the renewal OpportunityV2 by crmId. If missing, eagerly create it from SF via crmObjectService.fetchObjectwithout running enrichment (would race the engagement decider).
  3. Check for a half-finished orphan via findPendingRenewalSuccessorContract. If present, reuse it; else call createPendingRenewalContract.
  4. Attach the renewal opp as the contract's sequence-1 RENEWAL head.
  5. Resolve seed (resolveEngagementRenewalSeed) + prior head quote's pricingSpecId / dataSheetId; create the draft pricing quote.
Refresh branch (successor exists)
  1. Find the renewal's draft quote: prefer primary, else most recent non-deleted.
  2. If no draft exists yet → self-heal by creating one (same path as the create branch's final step).
  3. If a draft exists → call reseedRenewalQuote with targetQuoteId set explicitly to the draft's id.

Why explicit id? The default in reseedRenewalQuote goes through resolveCanonicalQuote and targets the primary. A renewal draft isn't primary yet, so without the override the refresh would silently no-op.

5. Idempotency & self-healing

The orchestrator runs from a BullMQ-retried listener, so every step has to tolerate replay. There are two failure points between "decision made" and "all rows written":

Crash pointWithout self-healWith this PR
After contract row created, before head opp attached findPendingRenewalSuccessorOpp (head-opp keyed) returns null → retry creates a second PENDING contract at the next sequence, indefinitely. findPendingRenewalSuccessorContract (membership-independent) finds the orphan and reuses it. Logged as "reusing half-finished PENDING renewal contract".
After head attached, before draft quote created Refresh branch finds no draft and noops. Refresh branch detects missing draft and falls through to the quote-create path. Same shape as the create branch's tail.
OpportunityV2 created concurrently by sync @unique crmId constraint throws. Catch & re-read via findByCrmId; only rethrow if it's still absent.

6. Two reactors, one orchestrator

The PR adds two listeners that both call ensureRenewalForClosedWonContract. They cover different signals.

engagementRenewalScaffoldReactor

Event: opportunity.updated

Triggers on:

  • The Closed Won transition itself (stage === 'Closed Won').
  • A later renewal-link change on an already-closed-won head (renewalLinkObserved === true) — the bind retry.

Gates: source=crm_webhook, version=v2, org allow-list, both engagement flags, and contract DealGroup must be ACTIVE.

renewalReconcileFromDealGroupReactor

Event: crm.deal_group_updated (new)

Fires when: Langchain's SF automation links the renewal opp onto the Deal_Group__c we wrote back. processAmpersandWebhook matches the row to our DealGroup by Deal_Group_Id__c and emits with the pre-resolved renewal SF id.

Inert until DEAL_GROUP_WEBHOOK_OBJECT is set — currently 'deal_group__c' in the constant but the subscription itself is the activation.

Why two listeners and not one? Kept separate so renewal-lifecycle failures retry independently of the SF writeback (and of each other). Both fan out from related but distinct CRM signals, and both ultimately dedupe through the same orchestrator.

7. The Deal_Group__c reconciliation event

A new event type rides on a new webhook branch in processAmpersandWebhook. The filter is the embedded Deal_Group_Id__c field we stamp on writeback — deal groups we didn't create won't carry an id we recognize, so they fall out at the prisma lookup.

// processAmpersandWebhook.ts — new branch
} else if (
  DEAL_GROUP_WEBHOOK_OBJECT &&
  objectName === DEAL_GROUP_WEBHOOK_OBJECT &&
  Array.isArray(payload.result)
) {
  for (const item of payload.result) {
    await processDealGroupResultItem({ item, svixId });
  }
}

// ...inside processDealGroupResultItem:
const ourDealGroupId = fields?.[DEAL_GROUP_ID_WEBHOOK_FIELD];
const dealGroup = await prisma.dealGroup.findUnique({
  where: { id: ourDealGroupId },
  select: { id: true, organizationId: true },
});
if (!dealGroup) return; // not one of ours

await tryEmit('crm.deal_group_updated', {
  organizationId: dealGroup.organizationId,
  entityId: dealGroup.id,
  payload: { dealGroupId: dealGroup.id, renewalOpportunitySfdcId, ... },
});

8. Enrichment dedupe

The other half of the dedupe story: when the renewal opp does eventually sync through CRM, the enrichment decider used to take the create-renewal path unconditionally. Now it checks first:

case 'create-renewal': {
  const existingRenewalOppId = await findPendingRenewalSuccessorOpp(
    intent.priorDealGroupId,
  );
  if (existingRenewalOppId) {
    logger.info(`[Langchain enrich] create-renewal SKIP — a PENDING renewal already exists ...`);
    return;
  }
  // ...fall through to original creation logic
}

So whichever signal arrives first — Closed Won on the prior, the Deal_Group__c link, or the renewal opp itself — exactly one PENDING renewal contract is created per prior.

9. What this PR doesn't change

10. Risks, gating & open extension points

Stubbed by design. findRenewalOpportunityId returns null in its current form — the create branch is a clean no-op until the org-specific SOQL is supplied. The file documents the exact plug-in point. The refresh branch is fully live today for any renewal that already exists.
Defense in depth. Gates evaluate cheapest-to-costliest: source=crm_webhookversion=v2 → stage match → org allow-list → engagement flags → DealGroup ACTIVE. By the time any prisma writes happen, six independent checks have passed.
Untested locally. Server suites need Redis/config infra; only typecheck + ESLint + prettier were run. The orchestrator unit tests use injected deps and are fully isolated, but the listener wiring will first execute on CI.

Open extension points (called out in the PR)