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.
getQuoteLinesForMonthlyMinimums byte-for-byte. The PLAID_MM_SINGLE_LINE experiment is removed.
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.
plaidWritebackPipelineV2 is turned on for the Plaid Test Org (e846ccc7…). Writeback is live there on deploy.
getRevenue, getTCV, or tiering. This is purely how engine output maps to SBQQ__QuoteLine__c fields.
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:
- MM count mismatch. An earlier prototype path (
PLAID_MM_SINGLE_LINE) had emitted a single non-segmented MM line and let CPQ segment it, chasing a sandbox "24 MM lines" blowup. The blowup turned out to be a non-deterministic SF-side injection — 0/7 reproductions across all variants, including re-pushing the original trigger opp. Fan-out is clean in every observed push; v1 prodQ-55179writes 12 segmented MM lines and the v2 path needs to match. - Whole-graph rollback on re-push. The atomic composite graph was folding in explicit per-OLI DELETEs and the un-primary PATCH on the old primary quote. Un-primary triggers CPQ's own cascade-delete of synced OLIs; the explicit DELETE then raced the cascade, returned
ENTITY_IS_DELETED, and rolled back the entire push (un-primary, opp PATCH, new quote, all QLIs). - Sandbox field rejections. Custom prod fields like
Product_GBT__care present in the synced Plaid catalog'sextraFieldsbut the sandboxSBQQ__QuoteLine__cdoesn't have the column — every QLI insert was failingINVALID_FIELD.
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
Gated on PLAID_MM_SINGLE_LINE. Prototyped to chase a "24 MM lines" sandbox blowup — which proved non-reproducible (0/7).
1..12
Dimensionretained
v1 parityyes · matches Q-55179
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.
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.
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 ?? {};
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
| Test | What it asserts now | Change 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
- No engine math touched —
getRevenue,getTCV, and tiering produce identical numbers. Engine output is unchanged. - No changes to Plaid v1 writeback. The legacy path is untouched.
- No other org allowlists change. Only the Plaid Test Org (
e846ccc7…) flips on; Plaid prod (0ea42205…) stays gated behind the Phase 2 diff-harness clean-diff window. - Discovery still produces
oliCrmIdsToDelete— only its emission into the atomic graph is conditional. Dry-run logs and metrics are unaffected. - Percent-tiered pricing branch is untouched (it already carried the discount natively via
SBCF_Percentage_Cost_Per_Transaction__c). The x2h fix is currency-tier only.
8. Risks & follow-ups
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.
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.
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.