Plaid v2: MM checkbox count scales TCV (dealops-3uy)

Wires v1's "monthly minimum type" checkbox-count multiplier into v2's contractual outputs (ramped TCV, flat-MM TCV, displayed minimum-commitment block) — narrowly scoped so non-Plaid orgs stay byte-identical.

Author mehulshinde PR #5857 Bead dealops-3uy Files 13 +/− +2882 / −509 Area Dealops 2 · pricing engine Status Open

What it adds

A single multiplier reader getMonthlyMinimumMultiplier() plus two new TCV methods getFlatMMTCVContractValue and getFlatMMTCVForMonthRange. The summary layer reads them via a 3-step fallback chain.

What it changes

For Plaid (flag-on), ramped and flat-MM TCV, plus the displayed minimumCommitment.* block, now scale by checked-box count. $120k → $240k → $360k as N goes 1 → 2 → 3.

What it preserves

Core math (getMonthlyMinimumPercentage, commitment rollups, applyMonthlyMinimumDiscount) is byte-identical on main for every non-Plaid org and for flag-off Plaid.

Opt-in

New spec flag scaleByAdditionalProductCodes. Default false → multiplier always 1. Plaid's pricingSpec.json opts in.

Engine (pricingEngineService)
Summary (display boundary)
CPQ import fallback
Plaid writeback (atomicity)
Untouched / byte-identical

1. Why this exists

In v1 (penguin), each checked "Monthly minimum type" box multiplied the contractual MM floor by the checkbox count. The rep picks 2 boxes, the MM floor doubles in TCV. v2 ignored this entirely — the term only fed the effectiveMonthlyMinimum formula variable used for SF writeback / approval-tier matching, but never landed in TCV or the displayed commitment block.

Live-test repro (Option 28, "dealops 07/30 (CAD cpq)"). A flat-MM Plaid quote at $10k/mo over 12 months stayed locked at $120,000 TCV across 1/2/3 boxes — even though the engine's multiplier was already firing for ramped quotes. Root cause: the summary's TCV fallback was getRampedTCVContractValue ?? getTCV, and flat-MM quotes hit unscaled getTCV.

The narrow-scope contract

The PR explicitly carves the work into two pieces. This PR ships the contractual display + TCV piece. Per-product / aggregate revenue scaling (the applyMonthlyMinimumDiscount chokepoint) is deferred to dealops-9za so this PR doesn't touch core pricing-engine math for any non-Plaid org.

2. What changes

2.1 The fallback chain (the central idea)

Every TCV read in calculateSummary and buildTcvSegments now goes through a 3-link resolver. Each link returns null on quotes it doesn't apply to, so non-Plaid orgs fall through to plain getTCV byte-identically.

getRampedTCVContractValue(y) ?? getFlatMMTCVContractValue(y) ?? getTCV(y)
Link 1 — ramped

Pre-existing. Live TCV path for ramped Plaid quotes. Gated to PER_MONTH_MINIMUM_RAMP_ORG_NAMES (Plaid Test Org only, prod Plaid joins later).

Link 2 — new

Flat-MM (non-ramped) Plaid path. Adds additive marginal (N−1) × floor × months to getTCV. Gated by scaleByAdditionalProductCodes — covers test + prod Plaid.

Link 3 — fallthrough

Unmodified getTCV. All non-Plaid orgs land here; the two upstream methods return null and the result is byte-identical to main.

2.2 The additive marginal (the math)

For flat-MM Plaid quotes with multiplier mult > 1:

marginal = monthlyMinimumAmount × (mult − 1) × monthsInRange
result   = getTCV(year).value + marginal

This is additive, not multiplicative or max. It mirrors v1's SF writeback, which emits N separate MM QLIs summed alongside usage QLIs — no max-with-usage clamp anywhere. v2's applyMonthlyMinimumDiscount clamps semantics differ; closing that pre-existing v1↔v2 gap is dealops-j4u, not this PR.

2.3 The screenshot scenario — concrete numbers

Boxes (N)
Before this PR
After this PR
v1 (penguin)
1
$120,000
$120,000
$120,000
2
$120,000
$240,000
$240,000
3
$120,000
$360,000
$360,000

Scenario: binding flat-MM quote, $10,000/mo floor, 12-month term, $1,000/mo usage → MM binds, base TCV = $120k. After fix-up, TCV scales linearly with N.

2.4 Engine: new methods and their gates

All three methods sit on PricingEngineService. Each has a tight set of early returns so non-Plaid orgs are provably unaffected.

Single source of truth for the multiplier. Reads the additional_monthly_minimum_product_codes term; falls back to the singular monthlyMinimumProductCode when no term is seeded.

getMonthlyMinimumMultiplier(): number {
  const scale = pricingSpecData.pricingEngineSpec
    ?.minimumCommitment?.scaleByAdditionalProductCodes;
  if (!scale) return 1;                          // non-Plaid identity
  const term = terms.find(t => t.id === ADDITIONAL_MM_TERM_ID);
  if (term?.userValue.type === 'array') {
    return term.userValue.value.filter(x => typeof x === 'string').length;
  }
  // CPQ-import fallback: singular code counts as 1
  const imported = minimumCommitment?.monthlyMinimumProductCode;
  if (typeof imported === 'string' && imported.length > 0) return 1;
  return 0;
}

2.5 Summary: where scaling lives

The summary scales response.minimumCommitment.{monthlyMinimumAmount, annualMinimumAmount, effectivePercentage} at the display boundary, not inside the engine's commitment readers. This keeps core math untouched.

const mmMultiplier = pricingEngineService.getMonthlyMinimumMultiplier();
const mmEnabledOnQuote = pricingQuoteInput.minimumCommitment?.enabled === true;
const commitmentScale = mmEnabledOnQuote ? mmMultiplier : 1;
const monthlyMinimum = { ...rawMonthlyMinimum, value: rawMonthlyMinimum.value * commitmentScale };
const annualMinimum  = { ...rawAnnualMinimum,  value: rawAnnualMinimum.value  * commitmentScale };

The mmEnabledOnQuote guard is a carve-out: when MM is disabled on the quote, the commitment readers return the usage-revenue fallback, which must not be scaled. Same carve-out the engine-internal version had.

2.6 CPQ-import fallback

Problem

CPQ import + renewal init carry the imported MM SKU on minimumCommitment.monthlyMinimumProductCode (singular), but never seed an additional_monthly_minimum_product_codes term. With flag on and no term, multiplier would be 0 → silent $0 TCV.

Fix (two halves)

Source side: assemblePricingQuoteInput materializes the singular code as a single-element term. Engine side: getMonthlyMinimumMultiplier defensively counts the singular code as 1 when the term is absent. Terms always win when both are present.

2.7 Ancillary: the cycle break

The constant ADDITIONAL_MONTHLY_MINIMUM_PRODUCT_CODES_TERM_ID moved into a new zero-import sibling file PlaidEffectiveMonthlyMinimum.constants.ts. Importing it from …attr.ts would re-enter the plaid/registry ↔ attr ↔ formulaUtils ↔ evaluationEngine ↔ variables/registry ↔ plaid/registry init cycle and hit a TDZ error at module-load time. The .attr.ts file re-exports the constant for backward compat.

3. The PlaidWriteBack atomicity rework (sibling change in this PR)

This PR also carries a substantial rework to PlaidWriteBack.ts (+216/−211) under dealops-1q8. It's logically separate from the multiplier work but ships in the same PR.

Before — non-atomic

Phase 1 preprocessOpportunityForPlaid fired two standalone SF writes (un-primary PATCH + OLI batch DELETE) before the composite request. A composite failure left the opp half-preprocessed: un-primaried quote, OLIs gone, no new quote.

After — atomic

Phase 1 is now pure-read discovery (discoverPlaidPreprocessRequirements). The un-primary PATCH and per-OLI DELETEs are folded into the composite/graph before the opp PATCH. SF's atomic rollback now covers them on failure.

Composite shape (with 1 primary quote + 3 OLIs)

#MethodTargetreferenceId
0PATCHSBQQ__Quote__c/Q-old{SBQQ__Primary__c: false}preprocess_unprimary
1–3DELETEOpportunityLineItem/oli-{n}preprocess_oli_delete_{n}
4PATCHOpportunity/{id}updateOpportunity
5POSTSBQQ__Quote__c (new)dealops_new_quote
6…POSTQLIsdealops_{n}

Other guardrails added: a single-attempt contract (no retry — partial-success risk on Ampersand timeouts outweighs benefit), and a budget guard that throws PLAID_COMPOSITE_GRAPH_TOO_LARGE before any Ampersand call when sub-request count would exceed SF's 500-cap.

4. What it doesn't change

5. Risks & intentional consequences

Display ×N, revenue ×1. While dealops-9za is open, Plaid flag-on quotes will show tcv.* and minimumCommitment.* scaled by N but the per-product revenue chart and revenue.annual stay at ×1. Reviewers comparing the two columns side-by-side will see the discrepancy. This is documented and accepted.
Reconciliation invariant holds. buildTcvSegments uses the same fallback chain for both year-loop and multi-commitment branches, so Σ segments = tcvAll for Plaid flag-on quotes across both 36-month year-based and 2×6-month multi-commitment shapes. Without the getFlatMMTCVForMonthRange threading, the trace would print an arithmetic-incorrect "Σ segments = tcvAll" equation or fire production log noise on every calc.
v1↔v2 non-binding gap preserved (not introduced). When usage > floor, v2's applyMonthlyMinimumDiscount applies multiplicative scaling that already reduces revenue below usage. The flat-MM additive marginal preserves this gap (it's constant in N, so N=1 and N=2 differ by the same −1 × floor × T amount as on main). Closing it is dealops-j4u.

6. Test coverage

One new spec file: pricingEngineMonthlyMinimumMultiplier.spec.ts (1345 lines, 47 tests, 4 describe blocks).

Block 1 — engine reader

Reader correctness across flag on/off × 0/1/2/3 codes × CPQ-import fallback; core math invariance across N; getRampedTCVContractValue scaling.

Block 2 — summary display

Display-block scaling for flag on × 0/1/2/3 codes; flag-off identity; MM-disabled carve-out (usage-revenue fallback stays unscaled).

Block 3 (new) — flat-MM TCV

Binding/non-binding × N=2/3; all early-return gates; partial-final-year clamp; sister getFlatMMTCVForMonthRange with overshoot clamping.

Block 4 (new) — summary integration

12-mo binding tcv.all scaling; 36-mo year-loop Y3+ coverage; 2×6-mo multi-commitment reconciliation (the critical Σ segments = tcvAll invariant).

Plus a new PlaidWriteBack.atomic.test.ts (886 lines, TDD-red contract tests for dealops-1q8 GAP-ATOMIC) and 276 lines of obsolete preprocess-phase tests removed from PlaidWriteBack.test.ts.

Regression validation: 465 tests pass across 7 touchpoint suites (pricingEngineService, pricingEngineServiceProratedAndOthers, monthlyMinimum, tcv, dealDiscount, evaluationEngine, and the new MM-multiplier spec).

7. Follow-up bead map

BeadScopePriority
dealops-9zaPer-product / aggregate revenue scaling via applyMonthlyMinimumDiscountOpen
dealops-ydoSF writeback parity — emit N MM-segment QLIs (mirrors beadops-bvv)Open
dealops-j4uAbsolute v1↔v2 TCV alignment (commitment-clamp vs floor-plus-extras)P1
dealops-99gFE checkbox 0↔1 transition destroys sibling stateOpen
(unbeaded)Add production Plaid to PER_MONTH_MINIMUM_RAMP_ORG_NAMESRamp rollout