AI pricebook resolver: fall back to CRM mappings
Plaid stores pricebook currency/type in crmOpportunityMappings, not selectorTags — the resolver now reads both.
AI quote generation threw selectorTags missing required fields for Plaid pricebooks because Plaid's metadata lives in crmOpportunityMappings, leaving selectorTags as {}.
When selectorTags.currency or selectorTags.pricebookType is absent, fall back to quoteCurrency / pricebook entries in crmOpportunityMappings (operator equals).
selectorTags remains the preferred source when present. Values are normalized: currency uppercased, pricebookType lowercased.
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.
resolvePricebookFromTerms. Pricebook selection logic, term parsing, and the loaded-pricebook shape are untouched.
2. Before / after
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.
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:
selectorTags (preferred).
getSelectorTagString(tags, 'currency') → string | null
Requires a non-empty trimmed string. Empty strings and non-string values fall through.
crmOpportunityMappings.
getEqualsMappingString(mappings, 'quoteCurrency') → string | null
Finds the entry where name matches and operator === 'equals', with a non-empty string value.
null.
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
falls back to crmOpportunityMappings when selectorTags are empty
Plaid-shaped pricebook: selectorTags: {}, mappings provide CPQ + USD. Expects currency: 'USD', pricebookType: 'cpq'.
prefers selectorTags over crmOpportunityMappings when both are present
Sets selectorTags to USD/cpq but mappings to EUR/Partnership. Asserts USD/cpq wins.
Three existing error-path tests retargeted from /selectorTags missing required fields/ to /selector metadata missing required fields/.
6. What it doesn't change
- Selection logic.
PricebookController.selectPricebookFromFieldsis unchanged — the upstream match still drives which pricebook is chosen. - The
LoadedPricebookshape. Same fields (id,name,currency,pricebookType) and same source row. - Prisma access pattern. Still one extra
findUniqueper AI quote to fetch the raw row forselectorTags. - Non-Plaid pricebooks. Orgs already populating
selectorTags.currency/selectorTags.pricebookTypehit the same code path as before (with new normalization applied — see callout below). - tRPC routes, types package, schema. No migrations, no contract changes.
7. Risks & notes
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.
selectorTags and crmOpportunityMappings JSON — so the next failing pricebook can be triaged without re-querying the DB.
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.