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.

PR dealops#5793 Author @mehulshinde Branch mehul/dealops-cv5 Files 4 +/- +558 / -47 Bead dealops-cv5 Follows dealops-8eo (#5740)

PROBLEM

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.

FIX

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.

FOLLOW-UP

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.

Field with FLS gap (stripped)
Field still emitted
Salesforce auto-derived
QLI write path

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.

OBSERVED FAILURE
Last reproduced 2026-06-03 against opportunity 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:

FieldDerived 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

BEFORE — REJECTED BY SF
// QLI body (single path)
CurrencyIsoCode: "USD"
SBQQ__PriceEditable__c: true
SBQQ__TotalDiscountAmount__c: 0
SBQQ__ProductCode__c: "INPRT - UF"
SBQQ__Description__c: "On-demand…"
...safeExtraFields
→ INVALID_FIELD_FOR_INSERT_UPDATE
→ composite PROCESSING_HALTED
AFTER — ACCEPTED BY SF
// QLI body (single path)
CurrencyIsoCode: "USD"
SBQQ__PriceEditable__c: true
// TotalDiscountAmount — SF derives
// ProductCode — SF derives from Product2
SBQQ__Description__c: "On-demand…"
...safeExtraFields
→ inserted; values back-filled by SF

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:

single stripped Standard one-line QLI.
segment stripped Rampable-support fan-out (e.g. FEE - SS5).
tier stripped Per-tier currency QLI children.
MM stripped Monthly-minimum segment QLIs.

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.

PathTest addedAssertion 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

8. Risks & follow-up

OUT OF SCOPE — TRACKED
Real-write e2e against Plaid prod SF (acceptance gate #4 on 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.
ROLLBACK
Pure removal of two field emissions, guarded by tests. Reverting the commit restores the prior behavior; no schema, no migration, no v1 impact. If a future Plaid SF permset change grants FLS R/W on either field, the named emit can be reintroduced and the rest-destructure entry removed — but there's no upside to doing so, since SF derives both server-side.