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).
The MM block in PlaidWriteBack was a TODO. v1 emits N quote-line-items per monthly minimum (one per subscriptionTerms month); v2 emitted zero.
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.
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.
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.
apps/server/src/utils/penguin/quote.ts:623 — getQuoteLinesForMonthlyMinimums. 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.
monthlyMinimumProductCode: z.string() to minimumCommitmentInputSchema. This is the v2 equivalent of v1's additionalData.additionalMonthlyMinimumProductCodes. Optional so older quotes keep working without a data backfill.
3. Schema surface change
One optional field on minimumCommitmentInputSchema:
// packages/types/v2/pricingQuoteInput.ts
monthlyMinimumProductCode: z.string().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.
- Now:
reconstructMinimumCommitmentwhen 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.
| Rung | Condition | Outcome |
|---|---|---|
| 1 | minimumCommitment absent or enabled !== true | Return []. No DB call. Build phase emits zero MM QLIs. |
| 2 | Enabled but no amount and no ramp | Return []. (Mid-edit UI state — fail open, not loud.) |
| 3 | Explicit monthlyMinimumProductCode on input | Use it directly. Skip the fallback table. |
| 4 | No explicit code | Derive via getMonthlyMinimumProductCode({pricebookName, currency}). |
| — | Fallback returns undefined | Throw PLAID_PIPELINE_MM_PRODUCT_CODE_UNRESOLVED. |
| — | Prisma lookup returns zero rows | Throw PLAID_PIPELINE_MM_SF_PRODUCT_NOT_FOUND. |
The fallback mapping (ported verbatim from v1's renewals.ts:1355)
| Pricebook | Currency | SF ProductCode |
|---|---|---|
CPQ | any | MIN - FLAT |
Partnership | USD | MIN - FLAT |
Partnership | EUR / GBP / CAD | MIN - FLATEC |
| anything else | — | undefined → 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:
| Field | Value | v1 reference |
|---|---|---|
SBQQ__ListPrice__c | Constant across all N. last(ramp).value if ramped, else flat amount. | quote.ts:666-671 |
SBQQ__OriginalPrice__c | Number(salesforceProduct.UnitPrice) | quote.ts:664 |
SBQQ__SegmentIndex__c | 1-based month index | quote.ts:692 |
SBQQ__SegmentLabel__c | "Month " + (i+1).padStart(2,' ') → "Month 1", "Month 10" | quote.ts:700 |
SBQQ__StartDate__c / EndDate__c | UTC-explicit setUTCMonth(i) / setUTCMonth(i+1) | UTC fix, drifts from v1's local |
AdditionalDiscountUnit__c + AdditionalDiscountAmount__c | Only emitted when listPrice - segmentPrice > 0; capped at 6 decimal digits | quote.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.
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.
60 MM rows missing. Rampable-support sits at slots #1/#2 — v1 has them at #3/#4. The diff harness reports both gaps.
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:
warnings.push({
code: 'mm_product_code_dropped',
message: `MM product code '${primaryCode}'
dropped — v2 has no surface (G1;
tracked by dealops-xq9.59)`,
});
// 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:
- 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
- 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
startDateterm → structured throw
- Empty
productCodes→ no Prisma call - Where clause:
organizationId+ProductCodein +CurrencyIsoCode+ pricebookdealopsInternalName - Prisma
Decimalcoerces to JSnumberon 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
- The shared QLI tail helper (
buildPlaidQliTailFields) — MM lines reuse it as-is via a syntheticsfRow-shaped object. - The rampable-support fan-out logic itself — only its position in the emit order changes (now second instead of first).
- Per-tier and per-product passes — untouched.
- The v2
Pricebookmodel — query still scopes via SF'sdealopsInternalName, the Plaid-internal source of truth for pricebook segmentation. - The
'mm_product_code_dropped'string in the warning-code union — kept for one release so legacy persisted warnings type-check. - The diff harness — rerun happens before merge, not in this PR.
10. Risks & open items
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.
(pricebook, currency). Existing quotes without the field continue to write back correctly.
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.