AI pricebook resolver: fall back to CRM mappings

Plaid stores pricebook currency/type in crmOpportunityMappings, not selectorTags — the resolver now reads both.

PR:dealops#6165 Author:yijunz166 Branch:yijunz/ai-pricebook-crm-mapping Files:2 Diff:+157 / −15 Area:dealops2 · aiQuoting State: open

Problem

AI quote generation threw selectorTags missing required fields for Plaid pricebooks because Plaid's metadata lives in crmOpportunityMappings, leaving selectorTags as {}.

Fix

When selectorTags.currency or selectorTags.pricebookType is absent, fall back to quoteCurrency / pricebook entries in crmOpportunityMappings (operator equals).

Preserved

selectorTags remains the preferred source when present. Values are normalized: currency uppercased, pricebookType lowercased.

Primary: selectorTags
Fallback: crmOpportunityMappings
Normalization
Error path

1. Why this exists

The Dealops 2 AI quoting pipeline calls resolvePricebookFromTerms to turn a user's term selection (pricebook name + currency) into a fully-formed LoadedPricebook with currency and pricebookType. Today the resolver re-fetches the raw Prisma row and reads those two values exclusively from selectorTags.

Plaid's pricebooks don't populate selectorTags — their selector metadata is encoded as crmOpportunityMappings with named fields like pricebook and quoteCurrency. The resolver therefore failed loudly for every Plaid AI quote, even when the matching pricebook was selected correctly upstream.

Scope is narrow. This PR only changes the metadata-resolution step inside resolvePricebookFromTerms. Pricebook selection logic, term parsing, and the loaded-pricebook shape are untouched.

2. Before / after

Before single source
const tags = raw.selectorTags ?? {};
const currency =
  typeof tags.currency === 'string'
    ? tags.currency : null;
const pricebookType =
  typeof tags.pricebookType === 'string'
    ? tags.pricebookType : null;

if (!currency || !pricebookType) {
  throw new Error(
    `selectorTags missing required fields...`
  );
}

Plaid pricebooks with selectorTags={} always hit the throw.

After selectorTags → mappings
const currency = normalizeCurrency(
  getSelectorTagString(tags, 'currency') ??
    getEqualsMappingString(
      result.pricebook.crmOpportunityMappings,
      'quoteCurrency',
    ),
);
const pricebookType = normalizePricebookType(
  getSelectorTagString(tags, 'pricebookType') ??
    getEqualsMappingString(
      result.pricebook.crmOpportunityMappings,
      'pricebook',
    ),
);

?? preserves selectorTags precedence; mappings are consulted only when the tag is missing or non-string.

3. How resolution flows

For each of currency and pricebookType, the resolver now walks a two-step chain before giving up:

1
Read from selectorTags (preferred). getSelectorTagString(tags, 'currency') → string | null Requires a non-empty trimmed string. Empty strings and non-string values fall through.
2
Fall back to crmOpportunityMappings. getEqualsMappingString(mappings, 'quoteCurrency') → string | null Finds the entry where name matches and operator === 'equals', with a non-empty string value.
3
Normalize. currency → trim().toUpperCase() · pricebookType → trim().toLowerCase() Empty result after normalization collapses back to null.
4
Throw if either is still null. The error now mentions selector metadata (not just selectorTags) and includes the full crmOpportunityMappings payload alongside selectorTags for diagnosis.

4. The mapping shape it parses

Plaid's crmOpportunityMappings is an array of objects. The resolver only trusts entries with operator "equals" and a non-empty string value:

[
  { displayName: 'Pricebook', name: 'pricebook',
    operator: 'equals', dataType: 'string', value: 'CPQ' },
  { displayName: 'Currency', name: 'quoteCurrency',
    operator: 'equals', dataType: 'string', value: 'USD' }
]

The lookup is name-keyed, so the mapping for currency resolution uses key quoteCurrency and the mapping for pricebookType uses key pricebook. Any other operator (e.g. in, notEquals) or non-string value is ignored — the resolver only handles the single-value equality case.

5. Test coverage added

New fallback

falls back to crmOpportunityMappings when selectorTags are empty

Plaid-shaped pricebook: selectorTags: {}, mappings provide CPQ + USD. Expects currency: 'USD', pricebookType: 'cpq'.

New precedence

prefers selectorTags over crmOpportunityMappings when both are present

Sets selectorTags to USD/cpq but mappings to EUR/Partnership. Asserts USD/cpq wins.

Updated error text

Three existing error-path tests retargeted from /selectorTags missing required fields/ to /selector metadata missing required fields/.

6. What it doesn't change

7. Risks & notes

Normalization is now applied to selectorTags too. Previously the resolver returned tags.currency verbatim. After this PR, currency is uppercased and pricebookType is lowercased regardless of source. If any consumer of LoadedPricebook compares these case-sensitively against the raw selectorTags value, behavior shifts. Worth a quick grep before merge.
Error message is more diagnosable. When the throw still fires, it dumps both selectorTags and crmOpportunityMappings JSON — so the next failing pricebook can be triaged without re-querying the DB.
TypeScript check inconclusive. The PR description notes tsc overflowed the stack in a symlinked worktree. Mocha test for this file passes; full repo typecheck should be run in CI before merge to confirm.