V3 renewal seed condenses duplicate seat rows

This PR adds a V3-only renewal seed pass that merges duplicate quantity-priced product rows into one quote line while leaving ambiguous recurring fee duplicates untouched.

Author: @pk675 PR: #6551 Base: main Head: pk/renewal-condense State: open Files: 17 Diff: +2136 / -3 Area: apps/server renewal seed

What it adds

A new condenseByProduct step in resolveEngagementRenewalSeed for V3 orgs.

Duplicate seat/entity rows are grouped by contract-line identity and merged through named rules.

What it changes

seats defaults to sumQuantityStackBands: add quantities, shift each amendment ladder above the prior volume.

3 → 1rows for upsold seats
What it preserves

Flat recurring duplicate rows are deliberately not merged in the diff, because replay rows are add-only and do not prove “re-price” vs “second purchase.”

$36kRN-005 remains duplicated
Review hotspot

The PR description says flat fees use “newest row wins,” but the code and tests explicitly keep duplicate flat_fee_recurring rows.

Resolve this mismatch before merge.

Renewal seed
condenseByProduct
Seat rule
Config schema
V3 gate
E2E coverage

1. Why this exists

Problem today

The renewal seed replays an engagement chain as one product row per chain step.

That is useful as history, but a renewal quote should show the rep the current contract line.

StepLinePrice
LandSeats4 @ $0
Upsell 1Seats10 @ $400
Upsell 2Seats15 @ $380
Desired quote shape

For quantity products, the rows are one contract line with one total quantity and one continuous blended ladder.

1–4$0
5–14$400
15–29$380

Revenue stays $9,700 because blended pricing bills each band’s slice.

Scope: This is Dealops 2 server renewal-seed logic, gated to V3 orgs via isV3OrgById. It is not a Prisma migration and not a new tRPC route.

2. What changes

01 Replay chain accumulateChainProducts emits one row per land / amendment step.
02 Apply seed rules One-time fees are already removed before condensing runs.
03 Check V3 Only V3 orgs run product-row condensing.
04 Condense groups Group duplicate contract lines and invoke a pricing-model rule.
05 Pre-V3 ladder path Commitment-ladder collapse now runs only for non-V3 orgs.
New server module

condenseByProduct.ts

  • Groups rows by contract-line identity.
  • Resolves a rule from org config, then defaults.
  • Returns { products, skips }.
  • Preserves first-appearance line order.
New rule module

renewalCondenseRules.ts

  • Defines sumQuantityStackBands.
  • Defines CONDENSE_RULES.
  • Defines DEFAULT_RULE_BY_MODEL.
  • Leaves unlisted models untouched.
Type surface

pricingSpecData.ts

  • Adds renewalCondense.
  • Allows "off".
  • Allows model-to-rule map.
  • Rule names: sumQuantityStackBands, none.
Seed/test setup

packages/prisma/seed.ts

  • Adds optional seeded pricingFlowType.
  • Marks campfire-test-org as DEALOPS_V3.
  • Updates reused DBs so V3-gated E2E paths do not silently skip.

Before / after by product model

Pricing model Before renewal seed After this PR Why
seats Multiple rows for the same SKU when seats/entities were upsold. condensed One row with summed volume and stacked blended tiers. Additional quantity is additive; each amendment’s ladder can be shifted above prior volume.
flat_fee_recurring Multiple rows for same recurring SKU. unchanged No default rule in the diff. Rows could mean re-price or second purchase; code refuses to guess.
Unhandled models Duplicate rows survive. unchanged Skip reason is logged. No default semantics are assumed for unknown models.
Commitment-ladder rows Handled by existing ladder-specific machinery. declined Product condense does not merge them. Price lives on tier values keyed by commitment tier, not only on the product row.

3. How it works

Grouping key

Rows are grouped as the same contract line only when these fields match:

  • contractLineKey(product)
  • commitment_id
  • productSpecId
  • bundleId
  • customName
  • selectorTags sorted by key
Why commitment id matters

A ramp has the same product in multiple commitment periods.

Including commitment_id prevents Year 1 and Year 2 from collapsing into one row.

PeriodSeat ladderResult
c11–4 $0, 5–14 $400one row
c21–4 $0, 5–14 $440one row
Rule resolution

The org config wins first, then the shipped default, then “leave it alone.”

ruleName = pricingSpec.pricingSpecData.renewalCondense?.[model]
        ?? DEFAULT_RULE_BY_MODEL[model]
Config value Behavior
renewalCondense: "off" Skip the whole condense pass.
{ "seats": "none" } Explicitly leave duplicate seat rows unchanged.
{ "consumption": "sumQuantityStackBands" } Opt an otherwise unhandled model into the seat-style stacking rule.
omitted Use defaults; currently only seats has a default rule.

Seat rule: sum quantity, stack bands

Input rows
land: 4 seats1–4 @ $0
upsell: 10 seats1–10 @ $400
upsell: 15 seats1–15 @ $380

Each row’s tiers are interpreted relative to that amendment’s own quantity.

Merged row
1–4$0
5–14$400
15–29$380

The merged row keeps the first row’s identity, dates, and tags; only volumeFlat and quotePriceFlat.tiers change.

Decline reasons are first-class

A rule can return { declined: string }; the caller keeps rows unchanged and logs the reason.

Decline case Reason
flattenTierStrategy !== "blended" Top-tier pricing would re-price every unit and change money.
Ramped or non-count volume Cannot safely sum the quantities.
Non-tiered row under seats The seat rule requires tier bands to stack.
Band gaps, inverted bands, bounds not starting at 1 The row’s ladder is not a contiguous relative ladder.
Ladder stops short of volume Some units are unpriced; merging would paper over broken data.
Commitment-ladder price present Product-row condensing would drop tier-keyed prices.

4. Tests and proof points

Unit tests

condenseByProduct.test.ts adds Mocha coverage for the new grouping, rule selection, decline paths, config overrides, order preservation, and purity.

25unit scenarios called out in PR notes
RN-006

Seat land plus a two-band seat upsell renews as one 24-seat line:

1–4 $0, 5–14 $400, 15–24 $380

$32,800renewal TCV
RN-007

Seats and entities condense independently in the same renewal, proving grouping is not coarser than spec id.

$35,000renewal TCV
RN-005

Duplicate Premium Support flat-fee rows are not condensed.

The expected renewal keeps both $5,000 and $6,000 support rows.

$36,000renewal TCV
Regression shape: The E2E seed now marks campfire-test-org as DEALOPS_V3, so these cases exercise the V3-only branch instead of accidentally passing through the pre-V3 path.

5. What it doesn’t change

Explicit non-goals in the diff

6. Risks / rollback / open questions

Open question: PR description conflicts with the diff.

The description says flat_fee_recurring uses “newest row wins” and cites a corrected Campfire RN-005 TCV of $31,000. The diff implements the opposite: no flat-fee rule, RN-005 expected $36,000, and comments say duplicate recurring rows are intentionally not condensed.

Reviewers should decide whether the description is stale or the implementation is missing a latestWins rule.

Behavioral risk

The seat rule assumes each amendment row’s ladder is relative to the quantity that amendment added.

If that assumption is false, the rule should decline; the tests cover many malformed ladder shapes.

Operational rollback

At config level, set "renewalCondense": "off" for a pricing spec to skip the pass.

At model level, set { "seats": "none" } to leave seat duplicates untouched.

Logging

When duplicate rows are left uncondensed, the server logs:

renewal seed: left duplicate product rows uncondensed

Payload includes organizationId, opportunityV2Id, and skip reasons.