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.
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.
quoteCurrency parameter, sourced from pricingQuote.input.targetCurrency, and include CurrencyIsoCode: quoteCurrency in the Prisma where. Each Product2Id now collapses to one matching-currency row.
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.
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.
- 30
CurrencyIsoCodediffs across 3 quotes - All affected QLIs were segment fan-outs for one product,
01t1Q00000877B1QAI("Platform Support Services - Basic") - Quote currency was USD; v2 was emitting EUR for every affected QLI
- Other rampable products on the same quotes emitted USD correctly — not a generic fallback bug
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 0 | 01t1Q00000877B1QAI | USD | PROD-A |
| row 1 (won, wrong) | 01t1Q00000877B1QAI | EUR | PROD-A |
| row 2 | 01t1Q00000877B1QAI | GBP | PROD-A |
| row 3 | 01t1Q00000877B1QAI | CAD | PROD-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 0 | 01t1Q00000877B1QAI | USD | PROD-A |
3. What changes
3a. Hook signature gets a required quoteCurrency
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 },
});
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.
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)":
CurrencyIsoCode: 'EUR' and that include: { parent, children } is preserved.
logWarning with the bead id, currency, and missing IDs in context. No exception.
logWarning call count is zero.
findMany.callCount === 0).
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
queryPricebookEntriesRaw(umbrella / parent / tier SOQL lookups) — may also be currency-scoped on Plaid, but goes through the SF SOQL layer, not Prisma. The harness didn't flag it.- v1 writeback (
utils/penguin/quote.ts) — unaffected. It queries by UUID PK so it never had the ambiguity. - Other multi-currency consumers of
salesforceProductoutside the Plaid writeback path. - Schema — no Prisma migration. Bug is fixed at the query level, not by adding a uniqueness constraint.
- Behavior when a row is missing — still falls back to quote-level defaults exactly as before; only adds an observability signal.
7. Risks, rollback, open questions
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."
- Do parent-emit and tier-children variants ever differ across currencies on Plaid in practice (different parentage / different tier structure)? The fix is correct either way, but it would be useful to confirm with the post-fix harness whether
extraFields,SBQQ__ProductCode__c, orSBQQ__Description__cdiff counts shift by the segment-fan-out cluster size — that would indicate currency-variant divergence in those fields rather than justCurrencyIsoCode. - Should
queryPricebookEntriesRawget the same treatment proactively, or wait for the next harness run to confirm? The PR defers; a follow-up bead will be filed if needed.
8. Validation procedure (from the bead)
- 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). - Confirm
CurrencyIsoCodediff count drops to 0. - 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.
- Spot-check a handful of
extraFieldsblobs across currency variants in the canary DB to confirm they don't carry per-currency localized values.