Merge co-term expansions keep the remaining-window period

Dealops 2 quote creation now treats Merge imported ECM expansions as mid-contract upsells: quote term stays full-contract, but the seeded commitment runs from today to the contract end.

Author @mehulshinde PR dealops#6656 State open Stack Dealops 2 / tRPC Files 24 Diff +1509 / -124 Base main Head mehul/merge-coterm-expansion-seed

What it fixes

Merge imported expansions were opening as full-term periods after the client rebuilt the server seed.

12 mobad reopened window
What it seeds

For gated co-term expansions: one commitment from expansionStartDate to the parent contract end.

5 moexample remaining window
What it preserves

subscriptionTerms remains the contract head’s full term, and the explicit seeded end date is not regenerated.

full termquote-level term
Blast radius

Flag-gated for Merge imported expansion classification and the mid-contract expansion seed model.

Flag-off behavior remains unchanged

Merge imported opportunity
Contract head quote
Expansion seed
Pricing flow spec
Client period UI
Feature flags

1. Why this exists

Current failure loop
  • ServerSeeds a remaining-window commitment for an expansion.
  • ClientSees commitments already present and skips defaulting the spec’s enablePeriod term.
  • ToggleReads multi-period as off because the term is unset.
  • ReopenDeletes the seeded commitment and rebuilds a full-term period from subscriptionTerms.
Desired contract shape
  • Quote termKeeps the parent contract’s full subscription term.
  • PeriodRuns from today / expansion effective date through the contract end.
  • ProductsStart empty, so the expansion prices like pure new business for added lines.
  • UI stateMulti-period mode is explicitly enabled server-side before first render.
Context: this is the product-code half split out from #6631. The historical ECM backfill is intentionally not part of this PR.

2. What changes

Before

Merge import came in as Standard + Monthly Overage.

Quote creation used the raw CRM type, so it did not consistently enter the expansion seed path.

When a period was seeded, the client could still rebuild it as a full-term period.

After

Flagged classification can reinterpret the Merge shape as Expand Opportunity only at quote lifecycle boundaries.

Co-term seed creates one remaining-window commitment and pre-sets period toggle terms.

PeriodTabs preserves server-provided periods on first render.

Lifecycle classification

  • classifyQuoteLifecycle.ts
  • isMergeImportedExpansionEnabledForOrg.ts
  • Keeps raw CRM type separate from quote lifecycle type.

Expansion seed

  • create.ts
  • buildExpansionSeedFromPrior.ts
  • Reads head term, seeds remaining window, upserts period toggles.

Client preservation

  • PeriodTabs.tsx
  • periodUtils.ts
  • ensureMultiCommitments only defaults when no commitments exist.

Spec mirroring

  • packages/types/v2/pricingFlowSpec.ts
  • Finds default-enabled period toggle variable IDs from category table specs.
Area Change Important constraint
Feature flags Adds classifyMergeImportedExpansions; extends anchorExpansionProrationToContractStart to Merge test/prod fixtures. Other orgs do not reinterpret Standard + Monthly Overage.
Quote creation Uses classifyQuoteLifecycle instead of raw getOpportunityType where creation needs lifecycle behavior. getOpportunityType still reports raw CRM type for approvals, writeback, renewal scaffolding, and other callers.
Expansion seed builder New co-term branch in buildExpansionSeedFromPrior. subscriptionTerms may intentionally disagree with the single commitment’s months.
Pricing quote service Adds preserveExpansionEndDate. Prevents fresh-quote defaulting from recalculating end date from the inherited full term.
Amendment context Passes lifecycle type into AmendmentContextService. Merge imported expansions stay out of amendment-delta TCV clamping.

3. How it works

1 Load opportunity Shape is Standard + Monthly Overage.
2 Classify lifecycle Flag + Merge org + primary-quoted parent required.
3 Resolve contract head Use direct parent first, DealGroup fallback only for unlinked imports.
4 Read flow spec Collect default-enabled period toggle term IDs.
5 Build seed One commitment: today through contract end.
6 Open quote Client preserves server period instead of rebuilding it.
Lifecycle gate

classifyQuoteLifecycle returns both crmType and lifecycleType.

  • crmType: raw Salesforce record type, still Standard.
  • lifecycleType: quote creation behavior, possibly Expand Opportunity.
  • isMergeImportedExpansion: lets prerequisite validation skip the direct-parent-only check.
Parent resolution

Classification needs a primary-quoted predecessor before widening the opportunity to an expansion.

  • Prefer Opportunity_Being_Expanded__c via lastOpportunityId.
  • Do not substitute a DealGroup parent when that direct parent is missing or not quoted.
  • Fallback to active DealGroup predecessor only for unlinked imports.
Seed shape

The co-term branch intentionally creates a mismatch:

  • subscriptionTerms: contract head’s full term.
  • multiCommitments[0].months: remaining window only.
  • startDate: expansion effective date.
  • endDate: first opportunity / contract head end date.
Co-term seed example
{
  "subscriptionTerms": 12,
  "startDate": "2026-08-13",
  "endDate": "2026-12-31",
  "multiCommitments": [{
    "id": "co-term-expansion-period-1",
    "start_date": "2026-08-13",
    "end_date": "2026-12-31",
    "months": 5,
    "commited_amount": 0,
    "tiers": []
  }]
}
Period toggle upsert

The server adds term values for each default-enabled period toggle discovered from the pricing flow spec.

{
  "type": "termInput",
  "id": "multiPeriodEnabled",
  "variableId": "multiPeriodEnabled",
  "userValue": {
    "type": "boolean",
    "value": true
  }
}
Client-side preservation rule

PeriodTabs and SingleCommitmentWrapper now call ensureMultiCommitments.

Existing commitments present

Return the original array by reference. No full-term default is created, so a server-provided co-term window survives first render and reopen.

No commitments present

Fall back to createDefaultCommitments(subscriptionTerms, ...), preserving old behavior for genuinely period-less quotes.

4. What it doesn’t change

Explicit non-goals

5. Tests and fixtures

Seed builder

buildExpansionSeedFromPrior.test.ts covers head-term preservation, one remaining-window commitment, period-toggle upsert, and no-op behavior outside co-term mode.

Spec helper

getDefaultEnabledPeriodVariableIds.test.ts mirrors the client rules: object form only, default-enabled only, de-duped, and visibility-toggle tables excluded.

Client utility

periodUtils.test.ts verifies an existing co-term period is returned unchanged and default commitments are created only when absent.

E2E regression

Merge test org adds a production-shaped Up-004 fractional-term upsell fixture for the current engine calculation.

Reported green: server Mocha suite 7251 passing, plus typecheck, eslint, and prettier clean.

6. Risks / rollback / open questions

Main risk

The server helper getDefaultEnabledPeriodVariableIds intentionally mirrors client logic. If CategoryStep changes its period-toggle rules later, this helper needs to move with it.

Behavioral fallback

If the Merge imported opportunity cannot resolve a primary-quoted predecessor, classification returns the raw type instead of throwing. That avoids blocking reps during hydration lag, but it also means the quote will not get the co-term expansion seed.

Rollback

Disable classifyMergeImportedExpansions to stop reinterpreting Merge imports. Disable anchorExpansionProrationToContractStart for Merge to stop the mid-contract co-term seed path.

Open naming note: the PR description calls out a new coTermExpansionSeed flag, but the diff implements the behavior through classifyMergeImportedExpansions plus anchorExpansionProrationToContractStart. Reviewers should confirm whether that flag name changed during the split or whether a dedicated flag is still intended.