Plaid v2 writeback: MM 12-segment fan-out, atomic-graph hardening, Test Org on

Normalizes Monthly-Minimum writeback to v1-parity 12-segment fan-out, drops the racing in-graph OLI DELETEs that were tripping ENTITY_IS_DELETED rollbacks, and flips plaidWritebackPipelineV2 on for the Plaid Test Org.

PRdealops#5909 Author@mehulshinde Basemain Headmehul/plaid-v2-mm-fanout-normalize Files4 +/-+534 / -85 StatusOpen SurfaceDealops 2 · writeback

What it changes
MM writeback now unconditionally fans out one segmented QLI per subscription month, matching v1's getQuoteLinesForMonthlyMinimums byte-for-byte. The PLAID_MM_SINGLE_LINE experiment is removed.
What it hardens
Drops per-OLI DELETEs from the atomic composite graph when an un-primary PATCH is present — CPQ cascades, our explicit DELETEs raced it and rolled back the whole push with ENTITY_IS_DELETED.
What it enables
plaidWritebackPipelineV2 is turned on for the Plaid Test Org (e846ccc7…). Writeback is live there on deploy.
What it preserves
Engine math is untouched. No changes to getRevenue, getTCV, or tiering. This is purely how engine output maps to SBQQ__QuoteLine__c fields.
MM fan-out (dealops-a6g)
Atomic-graph fix (dealops-s7q)
Sandbox field strip (cv5/c7u)
Feature flag rollout
Tier discount (x2h, follow-up tests)

1. Why this exists

Plaid v2 writeback maps Dealops engine output into Salesforce CPQ records. Three issues had been piling up in the sandbox push loop, all blocking the Plaid Test Org from going live on the v2 pipeline:

This PR closes dealops-a6g (MM normalization), folds in the already-in-tree fixes dealops-s7q / cv5 / c7u that the MM work depends on, and flips the org flag.

2. MM: from single line to 12 segments

Before — single-line experiment
MM lines1 non-segmented SegmentIndex Dimensionstripped v1 parityno
1 line · CPQ segments later

Gated on PLAID_MM_SINGLE_LINE. Prototyped to chase a "24 MM lines" sandbox blowup — which proved non-reproducible (0/7).

After — v1 parity fan-out
MM lines12 segmented SegmentIndex1..12 Dimensionretained v1 parityyes · matches Q-55179
1
2
3
4
5
6
7
8
9
10
11
12

Verified withDimensionLink=12 in live sandbox pushes.

The fan-out loop is now unconditional and walks subscriptionTerms directly. The env-gated branch is gone:

// dealops-a6g (RESOLVED): unconditional v1-parity fan-out.
for (const sfRow of mmSegmentEmissions) {
  const segmentKey = this.generatePlaidSegmentKey();
  // One segmented QLI per subscription month (v1 parity).
  const mmIterations = subscriptionTerms;
  for (let index = 0; index < mmIterations; index++) {
    // ... emit segmented QLI with SegmentIndex = index + 1
  }
}

3. Atomic graph: who deletes the stale OLIs?

The composite graph used to look like the left column below — un-primary first, then explicit per-OLI DELETEs, then the new writes. CPQ's cascade and our explicit DELETEs ran in the same atomic transaction and raced.

Before — race
PATCH old quote → Primary=false
↓ CPQ cascade fires
DELETE OpportunityLineItem/oli-1
DELETE OpportunityLineItem/oli-2
DELETE OpportunityLineItem/oli-3
↓ ENTITY_IS_DELETED (404)
⨯ whole-graph rollback
After — let CPQ own it
PATCH old quote → Primary=false
↓ CPQ cascade owns OLI cleanup
PATCH Opportunity
POST new SBQQ__Quote__c
POST QLI dealops_0 … dealops_N
✓ commit

The asymmetric branch

OLI DELETEs are only safe to drop when an un-primary PATCH is in the same graph — that's what triggers CPQ's cascade. If there is no primary quote, there's no cascade to rely on, and we must clean up the stale OLIs ourselves or they'll co-exist with the freshly synced QLIs and corrupt opp totals. So the builder splits:

if (existingPrimaryQuoteCrmId) {
  // un-primary PATCH only — CPQ cascade handles the OLIs
  preprocessSubRequests.push(unprimarySubRequest);
} else if (oliCrmIdsToDelete.length > 0) {
  // no primary → no cascade. Emit explicit DELETEs; safe, nothing to race.
  oliCrmIdsToDelete.forEach((oliId, idx) => {
    preprocessSubRequests.push({ method: 'DELETE', url: ..., referenceId: ... });
  });
}

Discovery still computes oliCrmIdsToDelete in both branches so dry-run logs and metrics stay meaningful, and a switch back to explicit DELETEs is one branch flip away if CPQ cascade proves unreliable on the Plaid SF org.

Trade-off acknowledged in code: correctness of OLI cleanup on re-push now depends on the target org's CPQ config actually cascading on SBQQ__Primary__c=false. If stale OLIs are observed after a re-push, the bead's fallback (option a) is to move preprocessing out of the atomic graph entirely, matching v1's non-atomic retried pre-step from apps/server/src/utils/penguin/quote.ts.

4. Sandbox field strips (cv5 / c7u)

Custom QLI fields the Plaid prod SF org defines but the sandbox org does not. The catalog sync carries them in extraFields; every QLI insert was failing INVALID_FIELD. buildPlaidQliTailFields destructures them out before the safe-fields spread:

const {
  SBCF_Net_Unit_Price__c: _omitNetUnitPrice,
  SBQQ__TotalDiscountAmount__c: _omitTotalDiscount,
  SBQQ__ProductCode__c: _omitProductCode,
  Product_GBT__c: _omitProductGbt,   // dealops-cv5
  ...safeExtraFields
} = extraFields ?? {};
This is a stopgap, not a permanent FLS strip. Sibling Plaid-custom fields likely also missing from the sandbox and likely to surface next: Discount_Group__c, Fee_Structure__c, Risk_Category__c, Product_Name_Summary_Variable_Mapping__c.

5. Feature flag

One line in packages/feature-flags/flags.ts appends the Plaid Test Org to the plaidWritebackPipelineV2 allowlist:

'e846ccc7-40b5-4897-a31b-761d6f51654c',
// Plaid Test Org - migrated to 2.0 (dealops-xq9.50;
// deploy gated on dealops-5yz wipe + reseed)

6. Tests

TestWhat it asserts nowChange shape
#2 primary + 3 OLIs un-primary PATCH, then opp PATCH, quote, QLIs — no preprocess_oli_delete_* sub-requests. Length drops 8 → 5. Inverted: was asserting DELETEs exist; now asserts they don't.
#4 no primary + 2 OLIs Explicit OLI DELETEs are emitted (no cascade to rely on). 5 sub-requests total. Reaffirmed: this branch was always safe and still emits DELETEs.
#11 500-subrequest cap Trips via 600 productIds (QLIs) rather than 600 OLI DELETEs. Re-driven: wirePipeline now takes productIds.
dealops-x2h (4 new) Per-tier discount surfaces as SBQQ__Discount__c + AdditionalDiscountUnit__c: 'Percent', with List == Original == rack. Covers v2-engine blended rack, per-tier rack via rawListPrice, SF-mirror fallback, and uplift/equal no-fabrication. New: tier-path analog of dealops-f2b. Code path lands here; the bead itself is tracked as a follow-up.

passing Full server mocha suite: 4347 / 0. Typecheck + lint clean. Live sandbox pushes: 12 clean MM segments across single-line, fan-out, and re-push runs.

7. What it doesn't change

8. Risks & follow-ups

Risk — CPQ cascade dependency. The dealops-s7q fix moves OLI cleanup responsibility from our composite graph to CPQ's own cascade on SBQQ__Primary__c=false. If a future Plaid SF org config has cascade disabled or behaves differently, re-pushes will leave stale OLIs on the opportunity. Mitigation is in-tree: discovery still computes the delete list, so re-enabling explicit DELETEs (or moving preprocessing out of the atomic graph per the bead's option a) is a focused change.
Risk — sandbox field strips are reactive. The cv5 / c7u strips remove fields we've already hit. The PR description names four more Plaid-custom fields likely to surface next. Expect at least one more strip PR before the Test Org is reliably green on arbitrary catalogs.
Follow-up — dealops-x2h tracked separately. The tier-path analog of the dealops-f2b discount fix lands as tests in this PR (4 new cases under PlaidWriteBack.test.ts), and the implementation in PlaidWriteBack.ts emits explicit per-tier discount via SBQQ__Discount__c. Repro recorded on the bead at sandbox quote a0vRt00000HCeMTIA1; closure tracked outside this PR.
Rollback. Single-org flag flip; revert is the one-line removal from the plaidWritebackPipelineV2 allowlist. Code-path reverts (MM fan-out, atomic-graph DELETE conditional) are isolated to PlaidWriteBack.ts and would re-enable the prior behavior verbatim.