Plaid v2: per-QLI SBQQ override fields, end-to-end

Threads three SBQQ override fields from Salesforce QLI read → reconstruction → schema → Plaid CPQ writeback so v2's payload stops dropping per-line values that v1 emits.

Author mehulshinde PR dealops#5720 Status Open Files 7 +/− +532 / −6 Track dealops-020.5 System Dealops 2 / Plaid CPQ

What it adds

Three per-QLI SBQQ override values now round-trip through v2's CPQ writeback: SBQQ__DefaultSubscriptionTerm__c, SBQQ__ProrateMultiplier__c, AdditionalDiscountUnit__c.

Why it matters

These fields show up as structural diffs in the v1→v2 CPQ diff harness because v2 was dropping them. Closing the gap is a prerequisite for live parity on the Plaid writeback.

How it stays clean

SF-specific values are namespaced under sfdcQliOverrides on the generic ProductInput, so the cross-CRM input surface doesn't accumulate raw SF field names.

Read (SOQL)
Reconstruct
Schema
Emit (Plaid writeback)
Salesforce

1. Why this exists

v1's CPQ writeback emits per-QLI values for three SBQQ override fields. v2's Plaid writeback never read them, never carried them through, and never wrote them — so the v1→v2 diff harness flagged the missing fields as structural diffs on every quote that used them.

The three dropped fields
  • SBQQ__DefaultSubscriptionTerm__c — number
  • SBQQ__ProrateMultiplier__c — number
  • AdditionalDiscountUnit__c — string / picklist
Where they were lost

v2's readSalesforceQuote didn't SELECT them, so the generic ProductInput shape had nowhere to put them, so PlaidWriteBack had nothing to emit. Three sequential gaps, one per layer.

2. The four-stage pipeline

The change is best understood as one value travelling through four stages. The carousel walks one override (SBQQ__ProrateMultiplier__c = 2) from Salesforce to the Plaid writeback payload.

3. The shape of sfdcQliOverrides

Rather than scattering raw SBQQ__* keys across the generic ProductInput surface, the PR adds a single nested, optional, nullable object. The keys are the raw SF field names — that's deliberate, so the writeback can splat them straight onto the QLI body without a rename layer.

// packages/types/v2/pricingQuoteInput.ts
sfdcQliOverrides: z
  .object({
    SBQQ__DefaultSubscriptionTerm__c: z.number().optional().nullable(),
    SBQQ__ProrateMultiplier__c:       z.number().optional().nullable(),
    AdditionalDiscountUnit__c:        z.string().optional().nullable(),
  })
  .optional()
  .nullable();
Why namespace it

Keeps SF-specific passthrough out of the cross-CRM input shape. A future Hubspot or Stripe path doesn't need to know these keys exist.

Why raw SF names

The emit step reads from this object and writes straight to the QLI body. No translation layer means no place to drop a field by typo.

Why optional + nullable

SF leaves most QLI rows null on these fields, and reconstruct may have nothing to fill in. undefined means "omit the whole object"; null on a leaf means "field absent on every source QLI".

4. The collapse rule: first non-null wins

A product group may map to many QLIs (flat row, tier rows, segment rows). The three override fields live on every QLI, but ProductInput has one slot per field. pickSfdcQliOverrides walks the group's QLIs in input order and picks the first non-null value per field, independently.

Predicate

Uses != null, not a truthy check. A legitimate numeric 0 is preserved; only null and undefined count as absent.

All-absent → omit

If every QLI in the group has all three fields null, the function returns undefined and the emitted ProductInput omits sfdcQliOverrides entirely — no empty object on the wire.

// reconstructProductInputs.ts — the core loop
for (const qli of qlis) {
  if (defaultSubscriptionTerm == null && qli.SBQQ__DefaultSubscriptionTerm__c != null) {
    defaultSubscriptionTerm = qli.SBQQ__DefaultSubscriptionTerm__c;
  }
  if (prorateMultiplier == null && qli.SBQQ__ProrateMultiplier__c != null) {
    prorateMultiplier = qli.SBQQ__ProrateMultiplier__c;
  }
  if (additionalDiscountUnit == null && qli.AdditionalDiscountUnit__c != null) {
    additionalDiscountUnit = qli.AdditionalDiscountUnit__c;
  }
}
Known sharp edge (called out in the source comment): for segmented products, the existing firstNonNullSub helper walks segment-index-sorted rows, while this helper walks source/input order. On well-formed inputs these collapse to the same value because the three overrides are product-level config (identical across a product's QLIs). On pathological per-line divergence they could disagree — but v2 can't faithfully reproduce per-line divergence against v1's per-QLI emission anyway. If the diff harness ever surfaces a real divergence here, the fix is to sort by segment index, or move these to per-tier / per-segment shapes.

5. Emit precedence: input wins over canary default

The Plaid writeback already had a generic per-Product extraFields blob that gets spread onto every QLI body. The new override values must win over that default when both are present, matching v1's Order_Form_Product_Name__c precedence pattern.

ScenariosfdcQliOverridesextraFields defaultEmitted value
Input wins525
Fallback to default22
Both absentkey omitted
Numeric zero override020
String override"Flat""Percent""Flat"
// PlaidWriteBack.ts — buildPlaidQliTailFields
const qliOverrideTerm =
  sfdcQliOverrides?.SBQQ__DefaultSubscriptionTerm__c ??
  (extraFields?.SBQQ__DefaultSubscriptionTerm__c as number | undefined);
// ... ProrateMultiplier and AdditionalDiscountUnit follow the same pattern

return {
  ...(extraFields ?? {}),
  // ... existing tail fields ...
  ...(qliOverrideTerm !== undefined
    ? { SBQQ__DefaultSubscriptionTerm__c: qliOverrideTerm } : {}),
  ...(qliOverrideProrate !== undefined
    ? { SBQQ__ProrateMultiplier__c: qliOverrideProrate } : {}),
  ...(qliOverrideAdditionalDiscountUnit !== undefined
    ? { AdditionalDiscountUnit__c: qliOverrideAdditionalDiscountUnit } : {}),
};
Subtle but important: the emit uses conditional spreads, not explicit key: undefined entries. The MM (multi-segment) fan-out at the call site emits AdditionalDiscountUnit__c: 'Amount' in discountFields and then spreads ...discountFields, ...tailFields. An explicit undefined in tailFields would silently overwrite that 'Amount'; a missing key spreads as nothing.

6. Call-site coverage: all three QLI shapes

The Plaid writeback builds QLIs in three places, one per shape. sfdcQliOverrides is threaded into buildPlaidQliTailFields at each call site so the behaviour is uniform.

Single (flat)

One QLI per product. product.sfdcQliOverrides ?? null passed through to the tail-field builder.

Tier fan-out

One QLI per tier row. All tiers in a product share the same override values (collapsed at reconstruct time).

Segment fan-out

One QLI per segment. Same shared override values across segments — co-exists with the existing per-segment renewedSubscriptionId logic.

7. What it doesn't change

8. Tests

12 new unit tests across the three layers, written TDD / RED-first. Server tsc --noEmit clean, prettier clean.

FileLayerWhat it pins down
readSalesforceQuote.test.ts Read SOQL SELECT contains all 3 fields; values round-trip on returned rows (numeric + string + null).
reconstructProductInputs.test.ts Reconstruct Flat / tiered / segmented all populate sfdcQliOverrides; first-non-null across the group; omit-entirely when all three are null.
PlaidWriteBack.test.ts Emit Input wins over extraFields; fallback to extraFields when no override; key omitted when both absent; numeric & string variants.

9. Open question: harness re-run

The live v1→v2 diff harness parity gate is not run yet — but the PR description is explicit that this is a canary-environment data gap, not a code issue with this change. The canary org's pricingSpec crmProductIds don't line up with active PricebookEntry rows in its Salesforce org, so replay fails in Phase 2 product-reference resolution before the emission this PR touches. Re-run is tracked separately, after the canary spec / SF-org data is reconciled.