Plaid v2: Monthly-Minimum Segment QLI Fan-Out

Ports v1's getQuoteLinesForMonthlyMinimums into PlaidWriteBack, closing the largest remaining v1↔v2 parity gap (60 missing MM rows in the canary diff).

PR dealops#5694 Author @mehulshinde Bead beadops-bvv Follows #5692 Files 8 Δ +1248 / −42 System Dealops 2 / Plaid

The Gap

The MM block in PlaidWriteBack was a TODO. v1 emits N quote-line-items per monthly minimum (one per subscriptionTerms month); v2 emitted zero.

The Fix

Adds monthlyMinimumProductCode to the v2 input, ports v1's (pricebook, currency) → SKU mapping, and fans out segment QLIs in phase 2 — emitted before rampable-support to match v1 ordering.

The Knock-On

Restoring MM lines in the correct slot also fixes a previously-unexplained SBQQ__Number__c ordering drift on rampable-support QLIs — they now land where v1 numbers them.

MM segment QLIs (new)
Rampable-support QLIs
Resolved / persisted
Schema surface

1. Why this exists

After #5692 (beadops-94r) landed the currency-scoped product lookup pattern, the canary diff harness still showed 60 MM rows tagged __missing_on_v2__. Every one of them traced back to a single TODO block in PlaidWriteBack.buildPlaidCompositeRequest that openly skipped the MM fan-out because v2's input schema had no surface for the user-selected MM SKU.

v1 reference path: apps/server/src/utils/penguin/quote.ts:623getQuoteLinesForMonthlyMinimums. v1 reads MM codes off additionalData.additionalMonthlyMinimumProductCodes (always a singleton array in practice for Plaid), looks up the matching SalesforceProduct row, and synthesizes N segment QLIs per code.

2. The four pieces

The port is a four-layer change: schema surface, importer round-trip, resolver, build-phase fan-out. Step through each layer to see what lights up.

3. Schema surface change

One optional field on minimumCommitmentInputSchema:

// packages/types/v2/pricingQuoteInput.ts
monthlyMinimumProductCode: z.string().optional(),
Why optional
  • Quotes built before this PR don't carry it — writeback falls back to a (pricebook, currency) derivation.
  • Non-Plaid orgs don't use this surface at all.
  • No data migration required.
Who populates it
  • Now: reconstructMinimumCommitment when importing existing SF quotes (round-trip preservation).
  • Later: a UI control once the user-facing surface lands.

4. The resolution ladder

Inside resolvePlaidMonthlyMinimumEmissions, the SF row resolution walks a four-rung ladder. Every rung has an early-return or a structured error — there's no silent fallthrough.

RungConditionOutcome
1minimumCommitment absent or enabled !== trueReturn []. No DB call. Build phase emits zero MM QLIs.
2Enabled but no amount and no rampReturn []. (Mid-edit UI state — fail open, not loud.)
3Explicit monthlyMinimumProductCode on inputUse it directly. Skip the fallback table.
4No explicit codeDerive via getMonthlyMinimumProductCode({pricebookName, currency}).
Fallback returns undefinedThrow PLAID_PIPELINE_MM_PRODUCT_CODE_UNRESOLVED.
Prisma lookup returns zero rowsThrow PLAID_PIPELINE_MM_SF_PRODUCT_NOT_FOUND.

The fallback mapping (ported verbatim from v1's renewals.ts:1355)

PricebookCurrencySF ProductCode
CPQanyMIN - FLAT
PartnershipUSDMIN - FLAT
PartnershipEUR / GBP / CADMIN - FLATEC
anything elseundefined → structured error

One v1→v2 simplification worth noting: v1 returns a string[] for legacy reasons; v2 returns the single string because the SF org never emits more than one MM line per (pricebook, currency) pair. The bead documents the choice.

5. The fan-out: per-segment QLI shape

Given a resolved MM SF row and subscriptionTerms = N, the build phase emits N QLIs sharing one SBQQ__SegmentKey__c. Each segment carries:

FieldValuev1 reference
SBQQ__ListPrice__cConstant across all N. last(ramp).value if ramped, else flat amount.quote.ts:666-671
SBQQ__OriginalPrice__cNumber(salesforceProduct.UnitPrice)quote.ts:664
SBQQ__SegmentIndex__c1-based month indexquote.ts:692
SBQQ__SegmentLabel__c"Month " + (i+1).padStart(2,' ')"Month 1", "Month 10"quote.ts:700
SBQQ__StartDate__c / EndDate__cUTC-explicit setUTCMonth(i) / setUTCMonth(i+1)UTC fix, drifts from v1's local
AdditionalDiscountUnit__c + AdditionalDiscountAmount__cOnly emitted when listPrice - segmentPrice > 0; capped at 6 decimal digitsquote.ts:705-712

Worked example: ramped MM [100, 200, 300, 500] over 4 months

listPrice = 500 (last ramp value) on every segment. discountAmount = listPrice − segmentPrice.

M1list 500price 100disc 400
M2list 500price 200disc 300
M3list 500price 300disc 200
M4list 500price 500no disc

6. Ordering: MM before rampable-support

This is the part that incidentally fixes the rampable-support diffs. v1 calls getQuoteLinesForMonthlyMinimums at quote.ts:514, before getQuoteLinesForMultidimensionalProducts. The MM QLIs land first in the composite request, so they consume the early SBQQ__Number__c slots. v2 must match.

Before this PR
#1ramp M1
#2ramp M2

60 MM rows missing. Rampable-support sits at slots #1/#2 — v1 has them at #3/#4. The diff harness reports both gaps.

After this PR
#1MM M1
#2MM M2
#3ramp M1
#4ramp M2

MM segments occupy slots #1/#2 (v1 order). Rampable-support shifts to #3/#4, matching v1's numbering. Both diff categories collapse.

7. The G1 warning, retired

Pre-port, the importer emitted mm_product_code_dropped every time MM was present — a "we saw the SKU but v2 has nowhere to put it" warning. With the surface now landed, the warning is gone:

Before
warnings.push({
  code: 'mm_product_code_dropped',
  message: `MM product code '${primaryCode}'
    dropped — v2 has no surface (G1;
    tracked by dealops-xq9.59)`,
});
After
// beadops-bvv: carry the code through
//   on minimumCommitmentInputSchema
//   .monthlyMinimumProductCode.
// PlaidWriteBack's MM emit reads it
// back during round-trip.
return {
  minimumCommitment: {
    ...,
    monthlyMinimumProductCode: primaryCode,
  },
  ...
};

The CpqImportWarningCode union keeps 'mm_product_code_dropped' in place for one release so persisted warnings on older imports still type-check; it's marked for removal in a comment.

8. Test coverage

The new PlaidWriteBack.mm.test.ts (669 lines, standalone) covers three layers:

Resolver (input side)
  • Disabled / no-amount → returns [] without a DB call
  • Explicit code is used as-is
  • Fallback for (CPQ, USD)MIN - FLAT
  • Fallback for (Partnership, EUR)MIN - FLATEC
  • Unrecognized pricebook → structured error
  • Empty Prisma result → structured error
Build phase (fan-out)
  • Empty emissions → legacy no-op preserved
  • Flat MM: N segments, constant ListPrice, no discount
  • Ramped MM: ListPrice = max, per-segment discount math
  • MM-before-rampable ordering invariant
  • Two MM products: distinct segment keys, shared within product
  • Missing startDate term → structured throw
DB query shape
  • Empty productCodes → no Prisma call
  • Where clause: organizationId + ProductCode in + CurrencyIsoCode + pricebook dealopsInternalName
  • Prisma Decimal coerces to JS number on return

Importer tests, flipped

Three existing tests in reconstructMinimumCommitment.test.ts flip from "asserts G1 fires" to "asserts G1 does NOT fire", plus three new tests assert monthlyMinimumProductCode round-trips correctly across all three shapes (flat single-QLI, equal-values collapsed-to-flat, ramped).

9. What it doesn't change

10. Risks & open items

Pricebook name coercion. The SalesforceProduct.salesforcePricebook.dealopsInternalName column is a Prisma enum (PRICEBOOK_NAME) — 'CPQ' | 'Partnership' | 'Hamster_pricebook'. loadMonthlyMinimumSalesforceProducts casts the free-form v2 Pricebook.name to that enum. If the name doesn't match an enum member, Prisma returns zero rows and the caller throws PLAID_PIPELINE_MM_SF_PRODUCT_NOT_FOUND — loud, not silent, but the error message points at "not found" rather than "name not in enum". Worth a friendlier error if this trips in practice.
Backward-compat is clean. The new schema field is optional and the writeback derives a default from (pricebook, currency). Existing quotes without the field continue to write back correctly.
Diff-harness rerun is the merge gate. The bead description is explicit: beadops-bvv stays open until the 60 MM __missing_on_v2__ diffs go to zero. The SBQQ__Number__c drifts on rampable-support QLIs should collapse in the same run.