Pin Plaid SF product lookup to the quote's currency

Stops loadSalesforceProductsByProduct2Ids from non-deterministically picking a currency variant on Plaid's multi-currency Salesforce org.

PR dealops#5692 Author @mehulshinde Bead beadops-94r Branch mehul/beadops-94r-plaid-writeback-currency-filter Files 3 Δ +354 / −15 Area Dealops 2 · CRM writeback

Problem
Plaid's SF org is multi-currency. The Prisma salesforceProduct table holds one row per (Product2Id, CurrencyIsoCode). The lookup queried only by Product2Id; a result.set reduction silently kept whichever variant Postgres returned last.
Fix
Add a required quoteCurrency parameter, sourced from pricingQuote.input.targetCurrency, and include CurrencyIsoCode: quoteCurrency in the Prisma where. Each Product2Id now collapses to one matching-currency row.
Evidence
Canary diff harness run 2026-05-28T22-15-41-544Z surfaced 30 CurrencyIsoCode diffs on segment fan-out QLIs. v1 emitted USD; v2 was emitting EUR because the reduction happened to land on the EUR row.
resolvePlaidProductReferences (caller)
loadSalesforceProductsByProduct2Ids (hook)
Prisma salesforceProduct (table)
Downstream QLI consumers

1. Why this exists

Plaid's Salesforce org has multi-currency enabled. In that mode, a single Product2Id in Salesforce produces one SalesforceProduct row per enabled currency in the Dealops Prisma mirror — USD, EUR, GBP, CAD, etc.

The Plaid v2 writeback used a lookup keyed only on (organizationId, Product2Id). The reduction loop ended with result.set(row.Product2Id, ...), so whichever row Postgres returned last won — with no ORDER BY, that's physical-row-order roulette.

Canary diff harness, 2026-05-28T22-15-41-544Z

v1 doesn't hit this because getSalesforceProductsFromIds queries by Prisma UUID PK, not by Product2Id. A UUID matches exactly one row.

2. The bug, concretely

For 01t1Q00000877B1QAI on Plaid's canary org, the table holds (roughly) these rows. The pre-fix query returned all of them; the reduction kept the last one Postgres handed back.

Pre-fix result Product2Id CurrencyIsoCode ProductCode
row 001t1Q00000877B1QAIUSDPROD-A
row 1 (won, wrong)01t1Q00000877B1QAIEURPROD-A
row 201t1Q00000877B1QAIGBPPROD-A
row 301t1Q00000877B1QAICADPROD-A

After the fix, the where clause carries CurrencyIsoCode: quoteCurrency, so Postgres returns exactly one row and there's nothing for the reduction to be lossy about.

Post-fix result (quoteCurrency = USD) Product2Id CurrencyIsoCode ProductCode
row 001t1Q00000877B1QAIUSDPROD-A

3. What changes

3a. Hook signature gets a required quoteCurrency

Before — PlaidWriteBack.ts
protected async loadSalesforceProductsByProduct2Ids(params: {
  organizationId: string;
  product2Ids: string[];
}): Promise<Map<string, PlaidSalesforceProductSummary>> {
  const { organizationId, product2Ids } = params;
  ...
  const rows = await prisma.salesforceProduct.findMany({
    where: {
      organizationId,
      Product2Id: { in: product2Ids },
    },
    include: { parent: true, children: true },
  });
After
protected async loadSalesforceProductsByProduct2Ids(params: {
  organizationId: string;
  product2Ids: string[];
  quoteCurrency: string;   // NEW, required
}): Promise<Map<string, PlaidSalesforceProductSummary>> {
  const { organizationId, product2Ids, quoteCurrency } = params;
  ...
  const rows = await prisma.salesforceProduct.findMany({
    where: {
      organizationId,
      Product2Id: { in: product2Ids },
      CurrencyIsoCode: quoteCurrency as CURRENCY_ISO_CODE,
    },
    include: { parent: true, children: true },
  });

3b. Caller plumbs the quote's target currency through

In resolvePlaidProductReferences, the currency is pulled off the quote input and forwarded into the hook call.

// beadops-94r: pin the multi-currency row lookup to the quote's
// target currency. Without this, the Prisma query returns one row
// per enabled SF currency variant per Product2Id and the reduction
// inside loadSalesforceProductsByProduct2Ids silently picks one at
// random.
const quoteCurrency = pricingQuote.input.targetCurrency;
const sfRowsByProduct2Id = await this.loadSalesforceProductsByProduct2Ids({
  organizationId,
  product2Ids: childCrmProductIds,
  quoteCurrency,
});

3c. Lossy reduction comment replaced with a correctness note

The pre-fix comment shrugged off duplicates as "shouldn't happen, last wins." That was exactly the bug. The new comment reflects that CurrencyIsoCode in the where clause makes the reduction degenerate.

3d. Defensive warn-log on missing variants

If fewer rows come back than Product2Ids asked for, the hook emits an errorNotificationService.logWarning with the missing IDs and the requested currency. It does not throw — existing if (!row) consumer branches handle missing rows by falling back to quote-level defaults, matching v1 parity.

errorNotificationService.logWarning(
  'beadops-94r: SalesforceProduct rows missing for some Product2Ids at requested currency; ' +
  'falling back to quote-level defaults',
  { organizationId, quoteCurrency, requestedCount, foundCount, missingProduct2Ids },
);

4. Why no extra plumbing for parent / tier paths

The hook uses include: { parent: true, children: true }. Each Prisma row's parentId FK points at the parent record of the same currency variant, and the children rows are likewise the same-currency tier rows. So once the top-level row is currency-pinned, the parent-emit path (concern #3) and the tier-children walk (concern #5) automatically follow into the matching currency without any new parameters.

Net effect on downstream consumers. Every site that read sfRow.CurrencyIsoCode, sfRow.extraFields, sfRow.ProductCode, sfRow.Description, sfRow.parent.Product2Id, or walked sfRow.children now sees data from the currency variant the quote actually uses. No code changes were needed at those sites.

5. Tests

Six new mocha tests in PlaidWriteBack.test.ts, grouped under "loadSalesforceProductsByProduct2Ids — multi-currency (beadops-94r)":

Where-clause shape
Asserts the Prisma call carries CurrencyIsoCode: 'EUR' and that include: { parent, children } is preserved.
Multi-variant collapse
Stubs the table with 4 currency variants of one Product2Id; the filter narrows to 1 row and the returned map has the requested currency.
Missing variant → warn, not throw
Empty result triggers logWarning with the bead id, currency, and missing IDs in context. No exception.
Happy path → no warn
All requested Product2Ids present at the requested currency: logWarning call count is zero.
Empty input fast-path
Zero Product2Ids skips the DB call entirely (findMany.callCount === 0).
Static plumbing guard
Reads PlaidWriteBack.ts from disk and regex-asserts that pricingQuote.input.targetCurrency, the forwarding call site, and the hook signature with quoteCurrency: string all still exist.

6. What this PR does not change

7. Risks, rollback, open questions

Type cast. targetCurrency is the v2 SupportedCurrency union, which is a strict superset of the Prisma CURRENCY_ISO_CODE enum (the v2 union has DKK, MXN, PLN; the table doesn't). For Plaid this never matters — the org only emits USD/EUR/GBP/CAD — but a non-Prisma-known code in the future would make Prisma throw at query time. The warn-log path would then surface it; the author judged that "better than silently picking a wrong-currency row."
Rollback. Pure code change to one file (plus tests). Reverting the diff restores prior behavior. No data migration, no flag.
Open questions.

8. Validation procedure (from the bead)

  1. Re-run the Plaid diff harness against the canary window used in apps/server/tmp/plaid-diff-harness/2026-05-28T22-15-41-544Z/ (or a fresh equivalent).
  2. Confirm CurrencyIsoCode diff count drops to 0.
  3. Diff the top-20 fields against the pre-fix run. Any field moving by the segment-fan-out cluster size (~30–48 in the original report) flags a currency-variant divergence worth recording.
  4. Spot-check a handful of extraFields blobs across currency variants in the canary DB to confirm they don't carry per-currency localized values.