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.
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.
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.
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.
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:
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.
quote.primary_set.
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.
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
}
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
| Symbol | Where | Role |
|---|---|---|
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
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:
"Renewal Opportunity" now works without a string allowlist update. Any org's renewal vocabulary works as long as the engagement hierarchy is populated.
"returns null when the opp is not the sequence-1 head") pins this behavior.
null before any flag eval, so the create flow's hot path is undisturbed.
8. What it doesn't change
- The legacy in-DealGroup seeding path is unchanged byte-for-byte. Anything returning
nullfrom the engagement path still falls through to it. - No new Prisma migrations. No schema changes.
- No changes to
createPricingQuoteor the tRPCupdatehandler. - The reactor is no-op for orgs without
useEngagementRenewalChain, and no-op for changes on contracts without a PENDING renewal successor. isRenewalon the client still only matches the literal"Renewal"— but the start-date field is already editable for Langchain's"Renewal Opportunity", so no UI unlock was needed for that org.- Edit preservation is not implemented. Per RFC decision 5, the re-seed overwrites products / commitments / dates wholesale.
9. Risks, open questions, follow-ups
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.