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.

PRdealops#6061 Author@mehulshinde TicketDEA-6425 SurfaceDealops 2 / CRM writeback Files3 Diff+327 / −92 FlagNone (no rollout gate)

Problem
Plaid writeback can pull multiple synced 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.
Fix
Extract duplicate-row ranking into a focused helper, add a transfer name discriminator (Vanilla / High Risk / Outbound Wires) above topology, and introduce an ambiguity guard that throws rather than silently picking by row id.
Scope
No feature flag. No behavior change for rows that ranking already resolved. Pure logic refactor + a stricter fallback path. Lives entirely in PlaidWriteBack.
PlaidWriteBack.ts (call site)
plaidSalesforceProductRowRank.ts (new helper)
Ranking signal (new)
Ambiguity throw

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:

DEA-6425 follow-up. The original ticket added selected-parent topology as a tiebreaker. This PR closes the remaining gap: when two duplicate rows are both under selected parents (a real case on transfer quotes), topology alone ties, and the old code silently broke the tie on stable row-id ordering — sometimes wrong.

2. What changes

Code moves: ranking logic leaves PlaidWriteBack.ts

Before

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.

After

New file plaidSalesforceProductRowRank.ts exports:

  • getPlaidSalesforceProductRowRank
  • isPlaidSalesforceProductRowBetter
  • findPlaidSalesforceProductRowAmbiguity

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).

1 parentMatch Row's parent Product2Id equals the v1 explicit hint.
2 parentNameMatch (new) Plaid transfer name discriminator: vanilla originators, high risk customers, outbound wires appears in both child and parent name.
3 selectedParentMatch Row's parent is in the set of parents the quote actually selected.
4/5 tierChildCount ↔ pricebookMatch Order flips on productIsTiered. Tiered: tier-count first. Flat: pricebook first.
6 hasAnyParent Prefer any parent over no parent.
row id (lexicographic) Final deterministic fallback. Only used when ranks are fully equal.

The new ambiguity guard

findPlaidSalesforceProductRowAmbiguity(rows, ctx) returns the set of top-ranked rows when:

  1. There's at least one parent-aware signal in the context (expectedParentProduct2Id or non-empty selectedParentProduct2Ids),
  2. Multiple rows tie at the top rank, and
  3. 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.

4. Test coverage

The existing test file moves its imports from ./PlaidWriteBack to ./plaidSalesforceProductRowRank and gains four cases targeting the new behavior:

Tie across selected parents → throws
Two rows under different but both-selected parents at the same pricebook. findPlaidSalesforceProductRowAmbiguity returns both. Old code would have picked by row id.
Transfer name discriminator → resolves
Three Transfer ACH variants (Outbound Wires / High Risk / Vanilla). Child name contains "Vanilla Originators"; only the Vanilla parent row wins, no ambiguity flagged.
Same-parent duplicates → not ambiguous
Two rows under the same parent. Row id ordering is still safe here — the guard explicitly does not fire.
No parent context → not ambiguous
Non-parent callers (no expectedParent, no selectedParents) keep the old fallback behavior. The guard requires parent context to engage.

5. What it doesn't change

6. Risks and open questions

New throw path. Quotes that previously completed writeback by silently picking the wrong parent will now fail with 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.
Rollback. Self-contained logic change in one feature area; revert is a clean git revert. No schema, no migrations, no flag state to clean up.
Discriminator list is hard-coded. 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.