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.
Merge imported expansions were opening as full-term periods after the client rebuilt the server seed.
For gated co-term expansions: one commitment from expansionStartDate to the parent contract end.
subscriptionTerms remains the contract head’s full term, and the explicit seeded end date is not regenerated.
Flag-gated for Merge imported expansion classification and the mid-contract expansion seed model.
Flag-off behavior remains unchanged
1. Why this exists
- ServerSeeds a remaining-window commitment for an expansion.
- ClientSees commitments already present and skips defaulting the spec’s
enablePeriodterm. - ToggleReads multi-period as off because the term is unset.
- ReopenDeletes the seeded commitment and rebuilds a full-term period from
subscriptionTerms.
- 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.
#6631. The historical ECM backfill is intentionally not part of this PR.
2. What changes
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.
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.tsisMergeImportedExpansionEnabledForOrg.ts- Keeps raw CRM type separate from quote lifecycle type.
Expansion seed
create.tsbuildExpansionSeedFromPrior.ts- Reads head term, seeds remaining window, upserts period toggles.
Client preservation
PeriodTabs.tsxperiodUtils.tsensureMultiCommitmentsonly 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
Standard + Monthly Overage.
classifyQuoteLifecycle returns both crmType and lifecycleType.
crmType: raw Salesforce record type, stillStandard.lifecycleType: quote creation behavior, possiblyExpand Opportunity.isMergeImportedExpansion: lets prerequisite validation skip the direct-parent-only check.
Classification needs a primary-quoted predecessor before widening the opportunity to an expansion.
- Prefer
Opportunity_Being_Expanded__cvialastOpportunityId. - Do not substitute a DealGroup parent when that direct parent is missing or not quoted.
- Fallback to active DealGroup predecessor only for unlinked imports.
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.
{
"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": []
}]
}
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
}
}
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
- Does not change raw CRM type reads from
getOpportunityType; the imported Merge shape still reportsStandardthere. - Does not add database tables, Prisma migrations, or schema columns.
- Does not include the historical ECM backfill; that is in the sibling PR from the split.
- Does not turn on AC sync for expansion seeds; inherited
multiPeriodAcSyncterms are still stripped. - Does not seed products onto expansions; the quote starts with an empty product slate.
- Does not reinterpret other orgs’
Standard+Monthly Overageopportunities unless the Merge-specific flag gate passes. - Does not change Langchain’s whole-contract restatement behavior when the mid-contract model flag is off.
5. Tests and fixtures
buildExpansionSeedFromPrior.test.ts covers head-term preservation, one remaining-window commitment, period-toggle upsert, and no-op behavior outside co-term mode.
getDefaultEnabledPeriodVariableIds.test.ts mirrors the client rules: object form only, default-enabled only, de-duped, and visibility-toggle tables excluded.
periodUtils.test.ts verifies an existing co-term period is returned unchanged and default commitments are created only when absent.
Merge test org adds a production-shaped Up-004 fractional-term upsell fixture for the current engine calculation.
7251 passing, plus typecheck, eslint, and prettier clean.
6. Risks / rollback / open questions
The server helper getDefaultEnabledPeriodVariableIds intentionally mirrors client logic. If CategoryStep changes its period-toggle rules later, this helper needs to move with it.
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.
Disable classifyMergeImportedExpansions to stop reinterpreting Merge imports. Disable anchorExpansionProrationToContractStart for Merge to stop the mid-contract co-term seed path.
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.