Fix Plaid CPQ import quote payloads
Seed Plaid imports with the term shapes the pricing page actually validates, and import percent-priced BPS/Transfer line items as quotePricePercent instead of dropping them on the floor.
subscriptionTerms as a text term, but CPQ imports were seeding it as {type:'count'} — failing UI term validation. Percent-priced Transfer/BPS QLIs were also being dropped.
subscriptionTerms as a string-typed term, sync the top-level numeric field from either shape, and reconstruct percent-priced QLIs as quotePricePercent with perTxnFloor / perTxnCeiling.
input.subscriptionTerms. Non-Plaid orgs are untouched on read; only get normalizes legacy count payloads for Plaid.
1. Why this exists
Plaid's pricing flow is unusual: its product spec renders the subscriptionTerms variable as a free-text input, not as a numeric counter. The pricing engine itself only cares about the numeric input.subscriptionTerms field, but the page-level term validator rejects a quote whose terms[] array contains a {type:'count'} shape where it expects {type:'string'}.
Two consequences fell out of that mismatch on the CPQ import path:
- Quotes imported from Salesforce CPQ for Plaid landed with a
count-typed term and could not be opened on the pricing page without an error. - Plaid's Transfer and BPS products price as a percent-of-transaction with optional fee caps — but the import only knew how to build
quotePriceFlat, so percent-priced QLIs either got the wrong shape or were silently mishandled.
This PR fixes both: it teaches the seed builder to write the Plaid-shaped term, it adds a small shared helper that keeps the top-level numeric field in sync from either term shape, and it teaches reconstructProductInputs to produce quotePricePercent with floors and ceilings when the source pricing spec is BPS-percent.
2. The two halves of the fix
Before / after — seed shape
{
id: 'subscriptionTerms',
variableId: 'subscriptionTerms',
userValue: {
type: 'count',
value: 36
}
}
{
id: 'subscriptionTerms',
variableId: 'subscriptionTerms',
userValue: {
type: 'string',
value: '36'
}
}
How the numeric field stays in sync
A new helper, apps/server/src/dealops2/services/pricingQuote/subscriptionTerms.ts, exposes two functions that callers use depending on direction:
syncSubscriptionTermsFromTerm(input)- Read the term's
userValue(whether it'scount,string, or a bare number), parse it, and write back toinput.subscriptionTerms. Called on every create / update / merge path insidePricingQuoteServiceand thepricingQuote.updatetRPC handler. normalizeSubscriptionTermsForTextTerm(input)- The mirror operation for reads: if the stored term is still a legacy
count, rewrite it as{type:'string', value:'36'}so the Plaid UI can render it. Only invoked when the requesting org is Plaid.
This replaces the previous inline 8-line block in PricingQuoteService.generatePricingQuoteInputWithDefaults that only handled type === 'count'.
Percent-priced QLI → quotePricePercent
Salesforce stores the rate on SBCF_Percentage_Cost_Per_Transaction__c (newly read in readSalesforceQuote.ts) and an optional fee cap on Total_Fee_Cap__c. When the matched product spec has calculationSpec.bpsPercentPricing, the import now builds a percentWithLimits structure:
{
type: 'percentWithLimits',
value: { type: 'percent', value: 0.35 },
perTxnFloor: { type: 'currency', value: 0.25, currency: 'USD' },
perTxnCeiling: { type: 'currency', value: 3, currency: 'USD' }
}
Floors come from spec.calculationSpec.bpsPerTxnFloor. Ceilings come from qli.Total_Fee_Cap__c; a null cap is preserved as uncapped via Number.MAX_SAFE_INTEGER, even when the spec has its own default ceiling.
Derived avgTransactionSize
If the matched spec sets requiresTransactionSize, the importer back-computes the implied average transaction size from the rate and the effective unit price:
avgTransactionSize = unitPrice / (rate / 100)
// 0.42 / (0.35 / 100) = 120
That value is attached as an avgTransactionSize term on the product so the engine has a concrete number to multiply against. Zero-rate / zero-price segments are skipped (no division), and the result is rounded to 6 decimals.
Tier / segment handling
| Shape | Behavior | Outcome |
|---|---|---|
| Flat percent QLI | Build percentWithLimits from the single row. |
Imported |
| Uniform segments (same rate, same cap, same avg txn) | Collapse to a single flat percentWithLimits. |
Imported |
| Tiered percent (volume tiers) | v2 has no tiered percent shape with limits. Skip. |
Warning · unsupported_bps_percent_shape |
| Non-uniform ramped segments | Can't be collapsed without changing pricing math. Skip. | Warning · unsupported_bps_percent_shape |
products.length === 0 plus exactly one warning, rather than silently emitting a tiered / ramped percent product that would price differently from the source SF quote.
Read path
The tRPC pricingQuote.get handler now calls normalizeSubscriptionTermsForTextTerm(result.input) when isPlaidOrgName(ctx.organization.name). This rewrites any historical Plaid quote that was persisted with a count-typed term into the string shape the pricing page expects — without any DB migration.
Write path
The pricingQuote.update handler calls syncSubscriptionTermsFromTerm in three places: on the raw updates.input, on the validation-stage merged input, and on the final engine-stage merged input. This guarantees that whether the client sends a string, a count, or just edits the numeric field, the engine and the term stay aligned.
Why org-gated on read but not on write
- Read: only Plaid's UI strictly requires the string shape, and we don't want to rewrite other orgs' historical
countterms. - Write: the sync helper is shape-agnostic — it accepts string, count, or number — so it's safe to run for every org.
3. Code walkthrough
The new helper
apps/server/src/dealops2/services/pricingQuote/subscriptionTerms.ts centralizes parsing. parseSubscriptionTermsValue accepts anything with a value property (or a bare number/string) and returns a positive finite number or undefined:
const parsed =
typeof raw === 'number'
? raw
: typeof raw === 'string' && raw.trim() !== ''
? Number(raw)
: undefined;
return parsed != null && Number.isFinite(parsed) && parsed > 0
? parsed
: undefined;
syncSubscriptionTermsFromTerm writes the parsed number back to input.subscriptionTerms. normalizeSubscriptionTermsForTextTerm additionally rewrites the term's userValue in place when it's still type:'count'.
The reconstruction split
In reconstructProductInputs.ts, buildProductInputForGroup now returns ProductInput | null so it can opt out of producing a product entirely. Each of the three sub-builders (buildFlatProduct, buildTieredProduct, buildSegmentedProduct) gained the same set of changes:
- Receive the matched
specso they can readspec.calculationSpec.bpsPercentPricing. - Detect percent-priced QLIs via
percentRateForQli(which guards againstnull/NaN). - Either return
nullwith anunsupported_bps_percent_shapewarning, or build a percent product and pipe it throughwithAvgTransactionSizeTerm.
The segment collapse check
The "uniform segments collapse to flat" rule is a small predicate:
function canCollapseBpsPercentSegments(qlis) {
if (qlis.length <= 1) return true;
const firstRate = percentRateForQli(qlis[0]);
const firstCap = qlis[0].Total_Fee_Cap__c ?? null;
const firstAvg = deriveAvgTransactionSizeForQli(qlis[0]);
if (firstRate == null) return false;
return qlis.every(qli =>
percentRateForQli(qli) === firstRate &&
(qli.Total_Fee_Cap__c ?? null) === firstCap &&
deriveAvgTransactionSizeForQli(qli) === firstAvg);
}
All three of (rate, cap, derived avg txn size) must match across every segment. The PR adds a test for the zero-rate edge case: segments where rate is 0 and cap is null collapse to a flat 0% with MAX_SAFE_INTEGER ceiling, and no avgTransactionSize term is attached because rate <= 0 short-circuits the derivation.
The seed assembler
One-line semantic change in assemblePricingQuoteInput.ts — the seed comment is updated to call out that the term and the top-level numeric field are deliberately different representations of the same value, and the test asserting {type:'count'} is flipped to {type:'string'}.
4. What this doesn't change
- The pricing engine's reading of
input.subscriptionTerms— still a plain number, still authoritative for math and writeback. - Non-Plaid orgs' read path —
normalizeSubscriptionTermsForTextTermonly fires insideisPlaidOrgName. - Tiered BPS pricing math — v2 still has no representation for tiered percent with limits; tiered BPS QLIs are skipped with a warning rather than approximated.
- Non-percent flat / tiered / segmented imports — those paths are unchanged when no QLI has a value in
SBCF_Percentage_Cost_Per_Transaction__c. - DB schema — no migration. Legacy
count-typed Plaid terms are rewritten on the fly on each read. - The
SBQQ__RenewedSubscription__crenewal-link handling (still ignored, still tracked bydealops-n5u.1).
5. Risks & open questions
count term is loaded, normalizeSubscriptionTermsForTextTerm mutates result.input in place before returning. The next update will persist the new shape. This is intentional — it's how the migration completes without a DB script — but reviewers should confirm there's no caller downstream of get that reads the term as count on the server side.
unsupported_bps_percent_shape warning carries the spec name and id, but reviewers should check how warnings surface to the import UI (the PR description doesn't say).
roundToNearest typing errors in pricingEngineService/monthlyMinimum.spec — unrelated to this PR. The three changed-area test suites pass.