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.
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.
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.
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.
1. Why this exists
For the Langchain engagement-contract model, when a deal hits Salesforce Closed Won two things have to happen in lockstep:
- Write the deal group back to Salesforce — already wired via
langchainDealGroupSync. - Keep an in-system renewal in sync — this PR.
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.
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.
opportunity.updated webhook for a Closed Won transition. processAmpersandWebhook maps fields and emits the platform event.
3. What changes, file by file
| File | Role | Δ |
|---|---|---|
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.
- Resolve the renewal SF opp id: prefer the caller-supplied override (Deal_Group__c path), else fall back to
findRenewalOpportunityId(SOQL onOpportunity_Replaced_By__c). - Look up the renewal
OpportunityV2bycrmId. If missing, eagerly create it from SF viacrmObjectService.fetchObject— without running enrichment (would race the engagement decider). - Check for a half-finished orphan via
findPendingRenewalSuccessorContract. If present, reuse it; else callcreatePendingRenewalContract. - Attach the renewal opp as the contract's sequence-1
RENEWALhead. - Resolve seed (
resolveEngagementRenewalSeed) + prior head quote'spricingSpecId/dataSheetId; create the draft pricing quote.
- Find the renewal's draft quote: prefer primary, else most recent non-deleted.
- If no draft exists yet → self-heal by creating one (same path as the create branch's final step).
- If a draft exists → call
reseedRenewalQuotewithtargetQuoteIdset 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 point | Without self-heal | With 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.
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.
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.
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
- Renewal activation. Flipping PENDING → ACTIVE and closing the prior when the renewal itself closes won — explicitly out of scope and flagged.
renewalReseedReactor(onquote.primary_set) — kept as-is. Because it only touchesisPrimaryquotes viaresolveCanonicalQuote, it no-ops on a draft renewal quote, so it can't double-reseed against this PR's explicit-id refresh.- SF writeback.
langchainDealGroupSyncstill owns the Deal_Group__c write; this PR only reads back from it. - Other orgs. The org allow-list (
Langchain,Langchain Test Org) plus the per-org engagement flags mean every listener is a no-op everywhere else. - The Opportunity-side renewal-link signal.
RENEWAL_LINK_WEBHOOK_FIELDis left asnull— the seam is in place but reconciliation flows through Deal_Group__c today.
10. Risks, gating & open extension points
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.
source=crm_webhook → version=v2 → stage match → org allow-list → engagement flags → DealGroup ACTIVE. By the time any prisma writes happen, six independent checks have passed.
Open extension points (called out in the PR)
- Eager create + enrich of the renewal opp (current code defers when the renewal opp isn't synced and no SOQL link exists, rather than fabricating CRM opp data).
- Renewal activation lifecycle — the natural follow-up PR.
- Opportunity-side renewal-link webhook field (
RENEWAL_LINK_WEBHOOK_FIELD) — already wired throughrenewalLinkObservedif a preferred signal emerges.