Auto-seed pending renewals when the prior contract changes

Phases 4 & 5 of RFC 2026-06-01: a platform-event reactor keeps PENDING renewal quotes in sync as the prior contract evolves, and an explicit re-seed mutation rebuilds a renewal when the rep moves its start date earlier.

Author @mileszim PR dealops#5742 Base main Head miles/contract-state-reactor-datechange Files 15 +/− +876 / −129 RFC 2026-06-01 Flag useEngagementRenewalChain

What it adds
A platform-event listener (renewalReseedReactor) that overwrites a PENDING renewal's quote whenever its prior contract's primary quotes or membership change, plus a new pricingQuotes.reseedForDateChange mutation invoked when a rep drags a renewal's start date earlier.
What it changes
buildEngagementRenewalSeed is split into a ctx-free core (resolveEngagementRenewalSeed) reusable from background jobs, plus a wrapper for the create flow. The renewal gate becomes structural (sequence-1 head of a renewal Contract DealGroup) rather than a CRM opp-type string match.
What it preserves
Quote IDs and primary status — the re-seed updates in place via PricingQuoteRepository.update, not the tRPC update handler, so it emits no events and can't re-trigger the reactor. Off-path quotes fall through to the normal edit path byte-for-byte.
Prior contract (locked-in)
PENDING renewal (shadow)
Reactor / mutation
Quote being overwritten

1. Why this exists

RFC 2026-06-01 introduces a single utility — computeContractStateAsOf(steps, asOfDate, expansionMode) — and progressively rewires three consumers around it. Phases 1–3 shipped in #5735 and fixed renewal creation (no more catalog-default surprises). This PR lands the remaining two consumers:

Consumer 2 — auto-seed reactor
A PENDING renewal is a shadow of the contract it succeeds. When that prior contract evolves (an amendment closes, an expansion gets added), the renewal quote silently drifts. The reactor keeps it consistent.
Consumer 3 — date-change re-seed
When a rep moves a renewal's start date earlier, they're saying "renew as if the contract ended on this date instead". The state-as-of utility makes that one-line: resolve prior state at the new date and overwrite.

2. Walkthrough: how the reactor fires

Step through what happens when an amendment closes on an active contract that already has a PENDING renewal sitting behind it.

3. Before / after: the seed builder split

The original buildEngagementRenewalSeed needed an AuthedTRPCContext (for ctx.prisma and ctx.user) and gated on a hard-coded set of CRM opp-type strings. Neither works for background jobs or for orgs whose CRM vocabulary differs.

Before

One function, ctx-required, CRM-vocabulary gated:

async function buildEngagementRenewalSeed({
  ctx,            // tRPC ctx required
  opportunityV2Id,
  organizationId,
  userId,
}) {
  const oppType = await getOpportunityType(...);
  if (!RENEWAL_OPP_TYPES.has(oppType)) return null;
  // ^ misses Langchain's
  //   "Renewal Opportunity"

  if (!useEngagement) return null;
  const priorDealGroupId = await resolvePrior...(
    opportunityV2Id, ctx.prisma,
  );
  // ... resolve + assemble seed
}
After

Ctx-free core + create-flow wrapper, structural gate:

// Core: usable from jobs + handlers
async function resolveEngagementRenewalSeed({
  opportunityV2Id,
  organizationId,
  oppType,              // only for DEA-6043 filter
  renewalStartOverride, // NEW — early renewal
  priorDealGroupId,
}) { /* ... */ }

// Wrapper: structural gate first
async function buildEngagementRenewalSeed(...) {
  const priorDealGroupId =
    await resolvePriorContractDealGroupId(oppId);
  if (!priorDealGroupId) return null;
  // ^ sequence-1 head check covers
  //   every org's renewal vocabulary
  if (!useEngagement) return null;
  return resolveEngagementRenewalSeed({...});
}

4. New surface area

SymbolWhereRole
resolveEngagementRenewalSeed buildEngagementRenewalSeed.ts Ctx-free seed builder. Accepts an optional renewalStartOverride so prior-contract state can be resolved as-of an arbitrary earlier date.
recomputeAndUpdatePricingQuote PricingQuoteService.ts Overwrites a quote's input and recomputes its output in place. Same compute as createPricingQuote, so output shape stays consistent.
reseedRenewalQuote RenewalReseedService.ts (new) Shared core: resolve seed → overwrite the target quote. Used by both consumer 2 and consumer 3.
findContractDealGroupIdForOpp EngagementRenewalChainService.ts Maps a quote.primary_set event's opp back to its prior Contract DealGroup.
findPendingRenewalSuccessorOpp EngagementRenewalChainService.ts Inverse engagement hop: prior contract → its PENDING renewal's head opportunity. Only PENDING successors qualify.
renewalReseedReactorListener platformEvents/listeners/renewalReseedReactor.ts (new) Subscribes to quote.primary_set, deal_group.opportunity_added, deal_group.opportunity_removed.
pricingQuotes.reseedForDateChange reseedForDateChange.ts (new) tRPC mutation. Returns { reseeded: false } off-path so the client falls back to a normal edit.
canReseedFromPriorContract get.ts + provider Server-computed boolean exposed on the quote payload. Gates the client's date-change prompt.

5. The date-change client flow

In DateTerm.tsx, when a renewal's start date moves earlier the rep gets a destructive-confirmation modal before anything fires:

const isMovingEarlier =
  !!isoString && !!currentIso && isoString < currentIso;

if (isStartDate && canReseedFromPriorContract && isMovingEarlier) {
  showConfirmationModal(
    {
      title: 'Rebuild renewal for earlier start date?',
      message: `Moving the start date to ${isoString} rebuilds this renewal
                from the prior contract as of that date, replacing the
                current products and pricing. Continue?`,
      confirmLabel: 'Rebuild quote',
      isDestructive: true,
    },
    () => {
      void reseedRenewalStartDate(isoString)
        .then((reseeded) => {
          if (!reseeded) handleValueChange(isoString); // fall back
        })
        .catch(() => showToast({ type: 'error', ... }));
    },
  );
  return;
}
handleValueChange(isoString); // unchanged path for all other edits

On a successful re-seed the provider calls window.location.reload(). This is deliberate, not lazy — see the open question below.

6. Loop-safety

No event cascade. The re-seed writes via PricingQuoteRepository.update directly, not the tRPC update handler. The repository write doesn't emit quote.updated or quote.primary_set, so the reactor can't observe its own output and re-trigger itself. The flag eval is also short-circuited behind the structural successor lookup, so the common no-op path stays cheap.

7. The structural renewal gate

The cleverest part of this PR is a one-line strengthening of resolvePriorContractDealGroupId:

// Only the renewal that *opens* a Contract DealGroup (sequence-1 head) seeds
// from the prior contract — an amendment/expansion living deeper in the same
// renewal contract (sequence 2+) renews from its own head, not the prior
// contract. This structural check is what identifies a renewal, NOT the CRM
// opp-type string (which varies per org — Langchain renewals don't all read
// "Renewal"/"Renewal Opportunity").
currentMembership.sequence !== 1

That single check replaces the old RENEWAL_OPP_TYPES set lookup. Three things follow:

Vocabulary-independent
Langchain's "Renewal Opportunity" now works without a string allowlist update. Any org's renewal vocabulary works as long as the engagement hierarchy is populated.
Tighter scoping
A sequence-2+ amendment inside a renewal contract no longer accidentally resolves a prior contract. A new test ("returns null when the opp is not the sequence-1 head") pins this behavior.
Cheap early-out
NB / amendment / expansion / off-hierarchy opps return null before any flag eval, so the create flow's hot path is undisturbed.

8. What it doesn't change

9. Risks, open questions, follow-ups

Reload-v1 is deliberate. The provider only hydrates its form at mount, and the container keys it by quote id, so neither a refetch nor a query invalidation refreshes it after a server overwrite. A full window.location.reload() is the safe choice. An in-place pricingQuoteForm.reset(...) is flagged in the RFC as a follow-up pending verification against the provider's autosave watchers.
Verification gap. Server + client typecheck, lint (0 errors), and prettier are clean; unit tests added for the new engagement lookups. The mocha suite and the client runtime were not run locally (infra / no dev server). Worth a quick manual smoke test on a Langchain renewal — move the start date earlier → confirm → page reloads showing the rebuilt quote.
"Overwrite, reconcile later" (RFC decision 5) means a rep who has been editing a PENDING renewal can lose those edits the moment an amendment closes on the prior contract. The RFC accepts this for now; reconciliation is future work.