Disambiguating duplicate Plaid Salesforce product rows
Plaid writeback now refuses to guess between conflicting parents when a child SKU has multiple synced SF rows — and breaks more ties before reaching that fallback.
SalesforceProduct rows for one Product2Id + currency. On transfer quotes, those rows point at different parents, so v2 sometimes attached children under the wrong parent or emitted a stray parent line.
PlaidWriteBack.
1. Why this exists
Plaid's Salesforce sync produces multiple SalesforceProduct rows that share a Product2Id and currency but differ on parent. This is normal on transfer products, where the same child SKU shape (e.g. "All other returns") is sold under several parents — "Vanilla Originators", "High Risk Customers", "Outbound Wires".
When v2 writeback resolved a child SKU to its SF row, the previous tiebreaker chain didn't always pick the parent that matched the quote. The downstream effects:
- Child lines attached under the wrong parent in Salesforce.
- An extra parent QLI emitted because the picked row's parent wasn't one the quote actually selected.
2. What changes
Code moves: ranking logic leaves PlaidWriteBack.ts
Ranking lived inline in PlaidWriteBack.ts as isPlaidSalesforceProductRowBetter. Test imported from the same file.
Duplicate detection used two Sets (seen + dup) and threw away the actual rows.
New file plaidSalesforceProductRowRank.ts exports:
getPlaidSalesforceProductRowRankisPlaidSalesforceProductRowBetterfindPlaidSalesforceProductRowAmbiguity
Duplicate detection groups rows into Map<Product2Id, rows[]> so the ambiguity check can inspect each group.
The new rank ladder
Each row is scored as a tuple; the first differing dimension decides. Tiered products promote tierChildCount above pricebookMatch; flat products do the opposite (a tiered row must carry its TIER children for per-tier fan-out).
Product2Id equals the v1 explicit hint.
vanilla originators, high risk customers, outbound wires appears in both child and parent name.
productIsTiered. Tiered: tier-count first. Flat: pricebook first.
The new ambiguity guard
findPlaidSalesforceProductRowAmbiguity(rows, ctx) returns the set of top-ranked rows when:
- There's at least one parent-aware signal in the context (
expectedParentProduct2Idor non-emptyselectedParentProduct2Ids), - Multiple rows tie at the top rank, and
- Those tied top rows point at different parent
Product2Ids.
If those conditions hold, the call site throws PLAID_PIPELINE_SALESFORCE_PRODUCT_ROW_AMBIGUOUS rather than letting row-id ordering decide. Same-parent duplicates are still resolved by row id.
3. How it works at the call site
Walk through the three views below to see how a Plaid transfer quote with duplicate rows flows through the new code.
While iterating products, PlaidWriteBack now records the v2 productSpec.name against the effective Product2Id so the ranker can later compare names like "Transfer (ACH) - Vanilla Originators" against parent display names.
const namesForProduct2Id =
childProductNamesByProduct2Id.get(effectiveCrmProductId) ?? [];
namesForProduct2Id.push(productSpec.name);
childProductNamesByProduct2Id.set(effectiveCrmProductId, namesForProduct2Id);
The map is then passed into the duplicate-rows query alongside the existing parent/pricebook hints.
The old detector used seen/dup sets and discarded the rows. The new code keeps the rows grouped so the ambiguity guard can inspect them.
const rowsByProduct2Id = new Map<string, typeof rows>();
for (const row of rows) {
const existingRows = rowsByProduct2Id.get(row.Product2Id) ?? [];
existingRows.push(row);
rowsByProduct2Id.set(row.Product2Id, existingRows);
}
const dupProduct2Ids = new Set<string>();
for (const [product2Id, productRows] of rowsByProduct2Id) {
if (productRows.length > 1) dupProduct2Ids.add(product2Id);
}
A warning is logged for any duplicate Product2Id (same as before). Then the new guard runs.
For each duplicate group, build a rank context and ask the helper if anything is still ambiguous:
const ambiguousRows = findPlaidSalesforceProductRowAmbiguity(
productRows,
{
expectedParentProduct2Id: expectedParentByProduct2Id?.get(product2Id) ?? null,
childProductNames: childProductNamesByProduct2Id?.get(product2Id),
selectedParentProduct2Ids,
quotePricebookSfdcId: quotePricebookSfdcId ?? null,
productIsTiered: tieredProduct2Ids?.has(product2Id) ?? false,
},
);
if (ambiguousRows.length > 0) {
throw new CrmWriteBackError(
`Plaid pipeline: ambiguous SalesforceProduct rows for Product2Id ${product2Id}; ...`,
'PLAID_PIPELINE_SALESFORCE_PRODUCT_ROW_AMBIGUOUS',
);
}
If no group is ambiguous, the existing reduction picks the best row per Product2Id using isPlaidSalesforceProductRowBetter — now with the same childProductNames hint threaded through.
4. Test coverage
The existing test file moves its imports from ./PlaidWriteBack to ./plaidSalesforceProductRowRank and gains four cases targeting the new behavior:
findPlaidSalesforceProductRowAmbiguity returns both. Old code would have picked by row id.
expectedParent, no selectedParents) keep the old fallback behavior. The guard requires parent context to engage.
5. What it doesn't change
- No feature flag. The PR description is explicit: no rollout gate ships here. Behavior changes on merge.
- Non-Plaid writebacks. All edits are confined to the Plaid-specific writeback files.
- Ranking semantics for already-resolved cases. When a row clearly wins on
parentMatch, pricebook, or topology, the picked row is the same as before. Only ties between distinct parents change outcome. - The duplicate warning log. Still emitted via
errorNotificationService.logWarningfor every duplicateProduct2Id. - Tier fan-out priority on tiered products.
tierChildCountstill beatspricebookMatchwhenproductIsTieredis true.
6. Risks and open questions
PLAID_PIPELINE_SALESFORCE_PRODUCT_ROW_AMBIGUOUS. That's the intended improvement — a refusal beats a wrong attach — but expect surfaced errors on quotes that were quietly broken before. The error payload includes per-row salesforceProductRowId, parent Product2Id, pricebook, and tier-child count so support can identify the offending rows quickly.
git revert. No schema, no migrations, no flag state to clean up.
PLAID_PARENT_NAME_DISCRIMINATORS currently contains three substrings (vanilla originators, high risk customers, outbound wires). Any new Plaid transfer parent-naming variant will need to be added here or fall back to selected-parent topology.