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.
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.
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.
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.
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.
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
One row:
SBQQ__ProductCode__c:TRFU - UFSBQQ__Product__c:sf-trfu-sharedSBQQ__ProductName__c: Transfer (Unauthorized return)SBQQ__RequiredBy__c: parent bundle QLI
Two specs share that SF product:
spec-trfu-vanilla— … Vanilla Originatorsspec-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:
matchedRows (unchanged)disambiguatePlaidTransferMatch(...); if it returns a spec, use itisZeroPricedCpqBundleParent(...), silently skipSBQQ__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:
| # | Source | How | Used 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:
- QLI is not itself a child (
SBQQ__RequiredBy__cis empty) - It has at least one child QLI
- It has no BPS percentage rate
- Its unit price is effectively zero (
Math.abs(...) <= 1e-9) - Either it or one of its children is a Plaid Transfer SKU
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
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.
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
- The
successpath throughmatchProductSpec— exact 1:1 matches still resolve identically. - Non-Transfer ambiguous matches still fall through to
unmatchedProductCodes. The new helper bails unless every candidate is a Plaid Transfer spec. - Minimum-commitment (MM) row handling — still dropped in this phase, still picked up by
reconstructMinimumCommitmentin Phase 3. - Tiered product logic, BPS/transaction-size math, average-transaction-size derivation — untouched. The PR note confirms ATS is left as-is because the source quote has no ATS field.
- No new dependencies, no schema changes, no tRPC route changes.
6. Risks and open questions
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.
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.