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.
A new condenseByProduct step in resolveEngagementRenewalSeed for V3 orgs.
Duplicate seat/entity rows are grouped by contract-line identity and merged through named rules.
seats defaults to sumQuantityStackBands: add quantities, shift each amendment ladder above the prior volume.
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.”
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.
condenseByProduct1. Why this exists
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.
For quantity products, the rows are one contract line with one total quantity and one continuous blended ladder.
Revenue stays $9,700 because blended pricing bills each band’s slice.
isV3OrgById. It is not a Prisma migration and not a new tRPC route.
2. What changes
accumulateChainProducts emits one row per land / amendment step.
condenseByProduct.ts
- Groups rows by contract-line identity.
- Resolves a rule from org config, then defaults.
- Returns
{ products, skips }. - Preserves first-appearance line order.
renewalCondenseRules.ts
- Defines
sumQuantityStackBands. - Defines
CONDENSE_RULES. - Defines
DEFAULT_RULE_BY_MODEL. - Leaves unlisted models untouched.
pricingSpecData.ts
- Adds
renewalCondense. - Allows
"off". - Allows model-to-rule map.
- Rule names:
sumQuantityStackBands,none.
packages/prisma/seed.ts
- Adds optional seeded
pricingFlowType. - Marks
campfire-test-orgasDEALOPS_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
Rows are grouped as the same contract line only when these fields match:
- contractLineKey(product)
commitment_idproductSpecIdbundleIdcustomNameselectorTagssorted by key
A ramp has the same product in multiple commitment periods.
Including commitment_id prevents Year 1 and Year 2 from collapsing into one row.
c11–4 $0, 5–14 $400one rowc21–4 $0, 5–14 $440one rowThe 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
Each row’s tiers are interpreted relative to that amendment’s own quantity.
The merged row keeps the first row’s identity, dates, and tags; only volumeFlat and quotePriceFlat.tiers change.
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
condenseByProduct.test.ts adds Mocha coverage for the new grouping, rule selection, decline paths, config overrides, order preservation, and purity.
Seat land plus a two-band seat upsell renews as one 24-seat line:
1–4 $0, 5–14 $400, 15–24 $380
Seats and entities condense independently in the same renewal, proving grouping is not coarser than spec id.
Duplicate Premium Support flat-fee rows are not condensed.
The expected renewal keeps both $5,000 and $6,000 support rows.
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
- No new API route and no client UI change.
- No database schema migration.
- No condensing for pre-V3 orgs; those keep the existing commitment-ladder renewal path.
- No default rule for
flat_fee_recurring; duplicates remain as replay emitted them. - No merge across commitment periods, bundles, custom names, or selector tags.
- No mutation of input product rows; the helper returns a new products array.
- No attempt to infer single-instance SKU semantics from row shape alone.
6. Risks / rollback / open questions
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.
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.
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.
When duplicate rows are left uncondensed, the server logs:
renewal seed: left duplicate product rows uncondensed
Payload includes organizationId, opportunityV2Id, and skip reasons.