Fixing Plaid Transfer child SKU matching in CPQ import

Adds QLI product names and three disambiguation strategies so Plaid's reused Transfer SKUs resolve to the right High Risk vs. Vanilla variant during import.

PRdealops#6067 Author@yijunz166 Basemain Files4 Diff+280 / −1 Areadealops2 / cpqImport

Problem

Plaid's CPQ catalog reuses one Salesforce Product2 (e.g. TRFU - UF) under multiple v2 specs — one per customer variant (High Risk, Vanilla, Outbound Wires). The matcher saw these as ambiguous and dropped them as unmatched.

Fix

Pull SBQQ__ProductName__c on each QLI, then disambiguate via three fallbacks: QLI name → CPQ parent name → unique Transfer variant in the quote. Also skip zero-priced bundle parents that lack a v2 spec.

Result

Live Plaid test quote: 7 Transfer products imported, 0 unmatched codes, 0 warnings. Zero-dollar bundle parent skipped intentionally. New regression test pair locks the behavior in.

QLI (quote line item)
ProductSpec (v2)
Ambiguous match
Resolved match
Skipped (bundle parent)

1. Why this exists

The CPQ importer reconstructs a Dealops v2 quote from a Salesforce CPQ quote by matching each SBQQ__QuoteLineItem (QLI) to a ProductSpec. The match key has historically been the Salesforce Product2.Id on the QLI.

That works when each SF product maps 1:1 to a v2 spec. Plaid's Transfer catalog breaks that assumption: a single SF product like "Transfer (Unauthorized return)" exists once in Salesforce, but Dealops models it as several distinct specs — High Risk Customers, Vanilla Originators, etc. — because pricing differs by customer segment.

Concrete failure

On Plaid's live test quote, the importer hit match.reason === 'ambiguous' for every Transfer child SKU and dropped them. Result: missing line items and a useless reconstructed quote. The bundle parent itself was also unmatched, since it has no corresponding v2 spec.

2. The ambiguity, illustrated

QLI from Salesforce

One row:

  • SBQQ__ProductCode__c: TRFU - UF
  • SBQQ__Product__c: sf-trfu-shared
  • SBQQ__ProductName__c: Transfer (Unauthorized return)
  • SBQQ__RequiredBy__c: parent bundle QLI
Candidate v2 specs

Two specs share that SF product:

  • spec-trfu-vanilla… Vanilla Originators
  • spec-trfu-high-risk… High Risk Customers

ambiguous Old matcher gave up here.

3. What changes

3a. Fetch the product name on each QLI

The SOQL field list in readSalesforceQuote.ts gains SBQQ__ProductName__c, a denormalized copy of Product2.Name on the QLI. The interface gains an optional string field.

// readSalesforceQuote.ts
const QLI_SOQL_FIELDS: ReadonlyArray<keyof SalesforceQuoteLineItem> = [
  ...
  'SBQQ__Product__c',
  'SBQQ__ProductCode__c',
  'SBQQ__ProductName__c',   // ← new
  'SBQQ__CustomerPrice__c',
  ...
];

The matching test had a hard-coded "12 v1-compatible fields" assertion that now just reads "all v1-compatible fields" — the field count is no longer pinned in the test name.

3b. New decision branch in reconstructProductInputs

The main loop previously only handled match.reason === 'success'; everything else fell through to "record as unmatched". Two new branches go in before that fallback:

1. success  →  push to matchedRows (unchanged)
2. ambiguous  →  try disambiguatePlaidTransferMatch(...); if it returns a spec, use it
4. otherwise → record SBQQ__ProductCode__c in unmatchedProductCodes (unchanged)

3c. The disambiguation cascade

The new helper disambiguatePlaidTransferMatch only fires when every candidate spec passes isPlaidTransferSpec (i.e. product code starts with TRF or the name normalizes to include "transfer"). It will never silently pick a winner for non-Transfer products.

It looks for a PlaidTransferVariant hint — one of 'high risk customers', 'vanilla originators', or 'outbound wires' — using three sources in order:

#SourceHowUsed when
1 QLI's own name & code Regex on SBQQ__ProductName__c; or product code patterns like /^TRF[A-Z]*HR\b/ (HR = High Risk) and /^TRF[A-Z]*VO\b/ (VO = Vanilla Originator) QLI's name explicitly mentions the variant
2 CPQ parent QLI Follow SBQQ__RequiredBy__c via qlisById, re-run source #1 on the parent Child SKU is generic (e.g. TRFU - UF) but parent is the variant-tagged bundle
3 Unique quote-wide variant Scan every QLI, collect variants; if exactly one shows up across the whole quote, use it Hidden fixed-fee SKUs that have no parent linkage but live in a single-variant quote

If a variant is found, candidate specs are filtered by getPlaidTransferVariantFromText(spec.name). The disambiguation only succeeds when exactly one spec matches the variant — otherwise it returns undefined and the row falls back to the unmatched bucket.

3d. Skipping zero-dollar bundle parents

Plaid's CPQ quotes include a bundle parent QLI (e.g. TRFSDHR — Transfer Same-Day High Risk) whose only job is to group child rows. It has no v2 spec and no real price. isZeroPricedCpqBundleParent drops it when all of these are true:

The last condition matters: it scopes the skip behavior tightly to Plaid Transfer, so an unexpected zero-dollar parent in some other product family still surfaces as unmatched rather than being silently swallowed.

4. The two new test cases

Test 1 — parent context

Scenario: ambiguous child TRFU - UF under a TRFSDHR parent named "Transfer (ACH) - High Risk Customers".

Expected: child resolves to spec-trfu-high-risk via the parent's name. Parent itself is skipped (zero-priced, no v2 spec). Final result: 1 product, 0 unmatched.

Test 2 — quote-wide context

Scenario: hidden TRFR - UF fixed fee with no parent linkage, but the quote also contains a TRFSDHR - BPS row whose name marks it as High Risk.

Expected: hidden fee resolves to spec-trfr-high-risk by the unique-variant fallback. Final result: 2 products, 0 unmatched.

5. What it doesn't change

6. Risks and open questions

Tight scope is load-bearing. The matcher will only auto-disambiguate when every candidate spec passes the Plaid Transfer sniff (TRF… code prefix or "transfer" in the name). That's the guardrail keeping this from silently mis-resolving unrelated ambiguous matches — but it also means the same shape of bug in another product family won't be fixed by this PR.
Quote-wide fallback is a heuristic. Strategy #3 (unique variant across the whole quote) is the loosest of the three. It's correct for single-segment quotes but would mis-attribute hidden fees in a hypothetical multi-segment quote. The PR's validation note implies Plaid quotes don't mix segments today; worth confirming if that assumption ever changes.
Validation. Live Plaid test quote reconstructs cleanly: 7 Transfer products, 0 unmatched, 0 warnings. The full cpqImport Jest suite passes (8 files, 138 tests). priorUsage.test.ts is Mocha-only and was run separately (13 passing). Server typecheck noted as blocked on unrelated main errors in pricing summary / monthly minimum files.