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.
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.
2. The two resolution paths, side by side
3. What changes
Touched files
| File | Role | Δ |
|---|---|---|
| expansionHydrationDecisionEngagement.ts | Pure decider — accepts new priorResolvedViaAccountEngagement input that bypasses pointer guards; adds renewal-already-heads-DealGroup idempotency noop. | +59 / −14 |
| LangchainOpportunityEnrichment.ts | Caller — wires resolveActiveContractByAccount helper and the fallback trigger; switches renewal carve-off to createPendingRenewalContract. | +149 / −16 |
| EngagementContractService.ts | Adds createPendingRenewalContract; createContractDealGroup takes optional status and skips the single-active assertion for non-ACTIVE rows. | +95 / −8 |
| dealGroupRepository.ts | Adds lightweight existsByOpportunityId(...) for the idempotency guard — boolean only, no hydrated Zod parse. | +16 / −0 |
| 3× test files | Decider, enrichment caller, and contract-service unit tests for fallback + PENDING coexistence + idempotency. | +476 / −13 |
Behavioral diff
- Renewal with no lastOpportunityId → silent noop.
- Renewal carve-off closed the prior Contract synchronously.
- Idempotency on renewal re-sync relied on the prior being CLOSED.
- 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.
| Method | Prior | New Contract | Use |
|---|---|---|---|
| createRenewalContract | CLOSED (synchronous) | ACTIVE RENEWAL | Legacy supersede; no longer called from this path |
| createPendingRenewalContract | Stays ACTIVE | PENDING RENEWAL | This 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:
- Caller short-circuit in LangchainOpportunityEnrichment: skip the account lookup entirely when existsByOpportunityId(currentOpp.id) is true. Saves three DB hits on every re-sync.
- 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
- Amendments (Expand/Upsell): still require lastOpportunityId. They target a specific prior opp's Contract; the account doesn't disambiguate.
- New Business: unchanged. Already had its own idempotency on currentOpportunityHasDealGroup; now just uses the cheaper existsByOpportunityId for it.
- Legacy (non-engagement) decider path: untouched. This change is scoped to the engagement-contract model branch.
- Existing noop reason strings: preserved verbatim so existing decider tests pass without modification.
- Single-active-Contract-per-Engagement invariant: still enforced. PENDING rows are exempt from the partial unique index by design.
- SF writeback / crmContractId: the PENDING renewal's crmContractId is left null per the RFC; expected to be backfilled on next writeback.