Drop two FLS-blocked fields from Plaid v2 QLI writebacks
SBQQ__TotalDiscountAmount__c and SBQQ__ProductCode__c are stripped from every Plaid v2 QuoteLineItem write payload because the DealOps Integration permset on Plaid prod Salesforce lacks FLS R/W on both.
Every Plaid v2 QLI sub-request was being rejected by Salesforce with INVALID_FIELD_FOR_INSERT_UPDATE, rolling back the entire composite via PROCESSING_HALTED.
buildPlaidQliTailFields stops emitting SBQQ__TotalDiscountAmount__c and SBQQ__ProductCode__c on all 4 QLI paths (single, segment, tier, MM), with a defensive rest-destructure strip on extraFields.
Real-write e2e against Plaid prod SF (acceptance gate #4) runs separately on mehul/goi-plaid-e2e-merge against a seeded PricingQuote before closing the bead.
1. Why this exists
v1 Plaid writeback succeeds because it authenticates as a Salesforce user with FLS R/W on these fields. v2 authenticates as the DealOps Integration permset, which on Plaid prod is missing FLS R/W on both fields. The composite API hard-rejects any insert/update that names a field the caller cannot write.
006UV0000048uac on Plaid prod, and re-confirmed against two fresh prod opps via composite dry-runs. Every QLI sub-request fails with INVALID_FIELD_FOR_INSERT_UPDATE; the parent composite rolls back as PROCESSING_HALTED.
Both fields are server-side auto-derived by Salesforce, so removing them from the client payload is not a data loss — it just stops the integration user from asserting a value SF was going to compute anyway:
| Field | Derived by SF from |
|---|---|
SBQQ__TotalDiscountAmount__c |
line discount + UnitPrice |
SBQQ__ProductCode__c |
Product2.ProductCode |
This PR follows the exact pattern used by dealops-8eo (#5740) for SBCF_Net_Unit_Price__c — a different field with the same shape of problem (non-writable for our integration user). SBQQ__Description__c has no FLS gap and is left intact.
2. Before / after on the QLI body
3. Coverage across the four QLI paths
The strip lives in a single helper, buildPlaidQliTailFields, which every QLI write path funnels through. That's why a one-helper change covers all four:
FEE - SS5).
4. How the strip works
Two independent guards in buildPlaidQliTailFields ensure neither field can reach the wire — one removes the named emit, the other defensively destructures the field out of the extraFields spread so even an untrusted upstream caller can't reintroduce it.
Guard 1 — rest-destructure on extraFields
const {
SBCF_Net_Unit_Price__c: _omitNetUnitPrice,
SBQQ__TotalDiscountAmount__c: _omitTotalDiscount, // new (cv5)
SBQQ__ProductCode__c: _omitProductCode, // new (cv5)
...safeExtraFields
} = extraFields ?? {};
The _omit* bindings are intentionally unused — they exist purely to peel keys off the object before it gets spread into the QLI body. noUnusedLocals is off in this package, but a void _omitFoo guard is added anyway as a belt-and-braces hint.
Guard 2 — drop the named emits
The named emit for SBQQ__TotalDiscountAmount__c: 0 (a v1 always-emit holdover) and the named emit for SBQQ__ProductCode__c: productCode (the dealops-020.2 Fix 2 from sfRow) are both removed. The local productCode derivation is also gone — there's no point computing a value that never leaves the function.
One related callsite also drops ProductCode from the helper input shape at PlaidWriteBack.ts:2100, since the helper no longer reads it:
sfRow: {
CurrencyIsoCode: sfRow.CurrencyIsoCode,
extraFields: sfRow.extraFields,
// dealops-cv5: ProductCode dropped — buildPlaidQliTailFields
// no longer reads sfRow.ProductCode (GAP-FLS strip).
Description: sfRow.Description,
},
5. Test coverage
The bulk of the diff (+517 lines) is test. Each of the four paths gets paired coverage: a "named-emit removed" test and a "defensively stripped from extraFields" test. The existing parity-snapshot test is also updated to assert these keys must be absent, not present.
| Path | Test added | Assertion shape |
|---|---|---|
| single | NOT emitted when extraFields omits it | !hasOwnProperty(body, key) |
| single | Defensively stripped when extraFields carries it | !hasOwnProperty(body, key) |
| segment | Stripped across all 3 segments of a rampable product | loop over reqs[2..4] |
| tier | Stripped on per-tier QLI even when tier child extraFields carries it | single-tier composite |
| MM | Stripped from all MM segment fan-out bodies | loop over MM sub-requests |
| parity | V1_ALWAYS_EMIT_KEYS snapshot updated |
SBQQ__TotalDiscountAmount__c removed from the always-emit set; explicit negative guard added |
The dealops-020.2 Fix 2 tests are also rewritten: the original case (single QLI emits both SBQQ__ProductCode__c + SBQQ__Description__c from the umbrella SF row) splits into a Description-only positive test plus a new negative test for ProductCode.
6. Spec note hand-patch
writebackSpec.json gets a 3-line patch describing the new runtime behavior for the two affected fields. The PR description calls out this was edited by hand, not regenerated:
// SBQQ__TotalDiscountAmount__c
- "value": "0 (auto-set by Dealops on every QLI)"
+ "value": "not emitted (GAP-FLS strip, dealops-cv5)"
- "notes": "QLI total discount amount. PlaidWriteBack hardcodes
- SBQQ__TotalDiscountAmount__c = 0 …"
+ "notes": "PlaidWriteBack intentionally does NOT emit
+ SBQQ__TotalDiscountAmount__c (dealops-cv5 GAP-FLS strip; SF
+ computes it server-side from line discount + UnitPrice; DealOps
+ Integration permset on Plaid prod lacks FLS R/W on this field —
+ emitting it triggered INVALID_FIELD_FOR_INSERT_UPDATE,
+ observed 2026-06-03)."
7. What it doesn't change
SBQQ__Description__c— no FLS gap. Still sourced from the umbrella SF row, still falsy-collapses empty strings toundefined.- v1 Plaid writeback path — untouched. v1 auths as a SF user with FLS on both fields and continues to emit them.
- Other QLI tail fields —
SBQQ__PriceEditable__c,SBQQ__OriginalPrice__c,CurrencyIsoCode,SBCF_Approval_Level__c,Order_Form_Product_Name__c,SBQQ__RenewedSubscription__call still emit per their existing rules. - The
SBCF_Net_Unit_Price__cstrip fromdealops-8eo— still in place; this PR adds two siblings, doesn't replace it. - MM-line
SBQQ__PriceEditable__comission (dealops-4r5/020.17) — preserved. - Composite request shape, ordering, or sub-request count — unchanged. Just two fewer keys per QLI body.
8. Risks & follow-up
dealops-cv5) is not in this PR. The harness lives on mehul/goi-plaid-e2e-merge and will be run separately against the seeded PricingQuote c30eb211-cf34-40b3-a654-6c449d5f542d on opp 006UV00000d8TRNYA2 before the bead is closed.