Renewal CRM enrichment: Account→Engagement fallback

When a Salesforce-created Renewal Opportunity carries no lastOpportunityId pointer, resolve its prior Contract via the Account's active Engagement instead — and create the renewal as a coexisting PENDING group rather than closing the prior.

Author@mileszim PRdealops#5823 Stateclosed Files7 +/−795 / 51 ScopeDealops 2 · CRM enrichment

Problem
A Renewal Opportunity imported from Salesforce silently no-ops — no Deal Group is created and it isn't linked to the engagement — when the renewal was created fresh in SF without a prior-opportunity pointer.
Fix
Add a RENEWAL-only fallback: when the pointer path yields no usable ACTIVE Contract, resolve the engagement via custom.accountId and carve the renewal off the engagement's current ACTIVE Contract.
Secondary change
Renewal carve-off now creates a coexisting PENDING Contract instead of closing the prior. A separate workstream supersedes the prior when the term actually rolls over.
Opportunity (renewal)
Account / Engagement
ACTIVE Contract DealGroup
PENDING Contract DealGroup
No-op / unusable

1. Why this exists

Under the engagement-contract model, RENEWAL opportunities carve off a new RENEWAL Contract DealGroup from a prior Contract. The decider locates that prior through the CRM lastOpportunityId pointer (Langchain's Opportunity_Being_Expanded__c).

If a renewal is created fresh in Salesforce — rather than cloned from the prior contract's opportunity — it carries no such pointer. decideEngagementHydration hits its "lastOpportunityId is blank" guard and noops. The account already has an ACTIVE Engagement with an ACTIVE Contract; the data to link the renewal is right there, but the decider refuses to look at the account.

Why account-by-account works for renewals specifically: renewals always target the engagement's current term — there's exactly one ACTIVE Contract per Engagement (a hard invariant). Amendments, by contrast, target a specific prior opp's Contract, so they still require the pointer.

2. The two resolution paths, side by side

3. What changes

Touched files

FileRoleΔ
expansionHydrationDecisionEngagement.tsPure decider — accepts new priorResolvedViaAccountEngagement input that bypasses pointer guards; adds renewal-already-heads-DealGroup idempotency noop.+59 / −14
LangchainOpportunityEnrichment.tsCaller — wires resolveActiveContractByAccount helper and the fallback trigger; switches renewal carve-off to createPendingRenewalContract.+149 / −16
EngagementContractService.tsAdds createPendingRenewalContract; createContractDealGroup takes optional status and skips the single-active assertion for non-ACTIVE rows.+95 / −8
dealGroupRepository.tsAdds lightweight existsByOpportunityId(...) for the idempotency guard — boolean only, no hydrated Zod parse.+16 / −0
3× test filesDecider, enrichment caller, and contract-service unit tests for fallback + PENDING coexistence + idempotency.+476 / −13

Behavioral diff

Before
  • Renewal with no lastOpportunityIdsilent noop.
  • Renewal carve-off closed the prior Contract synchronously.
  • Idempotency on renewal re-sync relied on the prior being CLOSED.
After
  • Renewal with no pointer → resolves prior via accountId → creates PENDING Contract.
  • Renewal carve-off creates a coexisting PENDING Contract; prior stays ACTIVE.
  • Idempotency dedupes on the renewal opp's own membership via existsByOpportunityId.

4. How it works

Trigger condition for the fallback

The fallback fires for RENEWAL opps when the pointer path didn't produce a usable ACTIVE Contract. "Usable" is stricter than just status === ACTIVE:

const pointerPriorIsUsableContract =
  priorDealGroupEngagement?.status === DealGroupStatus.ACTIVE &&
  priorDealGroupEngagement.type === DealGroupType.CONTRACT &&
  !!priorDealGroupEngagement.engagementId;

if (normalizedType === OpportunityType.RENEWAL && !pointerPriorIsUsableContract) {
  // ...account fallback
}

This deliberately covers three cases: no pointer at all, pointer to a CLOSED prior, and pointer to a legacy pre-backfill group (type=null / no engagementId). The reviewer notes flag this as slightly broader than a literal "no pointer" check — see Open Questions.

Account resolution helper

The helper is a three-hop lookup with no fan-out; the single-active-Contract-per-Engagement invariant guarantees the final pick is unambiguous.

// LangchainOpportunityEnrichment.ts
const account     = await accountRepository.findByCrmAccountId(orgId, crmAccountId);
const engagement  = await engagementRepository.findActiveByAccountId(account.id);
const contracts   = await dealGroupRepository.findByEngagementId(engagement.id);
const active      = contracts.find(c => c.status === DealGroupStatus.ACTIVE);
// → shape into EngagementPriorDealGroupSnapshot, pass to decider

Decider: guard restructure

The pointer guards (lastOpportunityId is blank, prior opportunity not yet in DealOps DB) are now nested under !priorResolvedViaAccountEngagement. Every existing noop reason string is preserved byte-for-byte — the PR description explicitly calls this out as traced against existing tests.

if (!priorResolvedViaAccountEngagement) {
  if (!lastOpportunityId || lastOpportunityId.trim() === '') {
    return { kind: 'noop', reason: 'lastOpportunityId is blank' };
  }
  if (!priorOpportunityV2Id) {
    return { kind: 'noop', reason: 'prior opportunity not yet in DealOps DB' };
  }
}
// Contract-shape guards (type, engagementId, status=ACTIVE, not-already-member)
// run UNCONDITIONALLY below — fallback prior must still be a valid Contract.

PENDING coexistence at the service layer

The renewal carve-off no longer closes the prior. Instead, createPendingRenewalContract creates the new RENEWAL Contract with status=PENDING at max(engagementSequence)+1. The single-active partial unique index is unaffected — it only constrains ACTIVE rows.

MethodPriorNew ContractUse
createRenewalContractCLOSED (synchronous)ACTIVE RENEWALLegacy supersede; no longer called from this path
createPendingRenewalContractStays ACTIVEPENDING RENEWALThis PR — coexists until a later workstream rolls the term

Idempotency: two distinct guards

Because the prior no longer flips to CLOSED on renewal carve-off, a re-sync can't rely on the not-ACTIVE guard to absorb it. The new guard dedupes on the renewal opp's own membership:

  1. Caller short-circuit in LangchainOpportunityEnrichment: skip the account lookup entirely when existsByOpportunityId(currentOpp.id) is true. Saves three DB hits on every re-sync.
  2. Decider belt-and-suspenders: even if the caller didn't short-circuit, the decider returns noop · renewal opp already heads a DealGroup when currentOpportunityHasDealGroup is set.

5. What it doesn't change

6. Risks, rollback, open questions

Trigger scope is intentionally broad. The fallback fires whenever the pointer didn't yield a usable ACTIVE Contract — including "pointer found a CLOSED prior" and "pointer found a legacy pre-backfill group." Reviewer note flags this as easy to tighten to priorDealGroup === null if a narrower trigger is preferred. The author's stance is that the current shape correctly handles legacy-data orgs being backfilled.
PENDING scaffold interaction. If engagementRenewalScaffoldReactor has already created a PENDING renewal Contract for the engagement, this fallback won't adopt it — the helper picks the ACTIVE Contract and creates a fresh RENEWAL at max(sequence)+1, leaving the scaffolded PENDING one orphaned alongside. That scaffold's SOQL is stubbed today, so this is theoretical, but worth confirming before enabling in an org where the scaffold goes live.
Mocha suites not run locally. The harness eagerly initializes Redis/the job queue at module load, which wasn't available. Relying on CI. Typecheck, ESLint, Prettier all pass. The guard restructure was traced by hand against every existing decider test to confirm behavior preservation — high confidence on the decider, slightly less on the enrichment caller.
Rollback shape. The fallback path is gated by priorResolvedViaAccountEngagement in the decider and by the explicit if (normalizedType === RENEWAL && !pointerPriorIsUsableContract) block in the caller. Reverting the caller's helper invocation disables the fallback without touching the decider. Reverting the createPendingRenewalContract call back to createRenewalContract restores synchronous prior-close.