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.

PR dealops#6060 Author @yijunz166 Branch yijunz/plaid-cpq-import-payload-fix Files 11 +/- +736 / -24 Area Dealops 2 · CPQ import Org Plaid

Problem
The Plaid pricing page expects 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.
Fix
Seed 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.
Preserved
Engine writeback still reads the numeric input.subscriptionTerms. Non-Plaid orgs are untouched on read; only get normalizes legacy count payloads for Plaid.
subscriptionTerms flow
BPS / percent pricing
Warnings & skips
Plaid-only behavior

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:

  1. 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.
  2. 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

Before · rejected by UI
{
  id: 'subscriptionTerms',
  variableId: 'subscriptionTerms',
  userValue: {
    type: 'count',
    value: 36
  }
}
After · passes Plaid validator
{
  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's count, string, or a bare number), parse it, and write back to input.subscriptionTerms. Called on every create / update / merge path inside PricingQuoteService and the pricingQuote.update tRPC 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'.

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:

  1. Receive the matched spec so they can read spec.calculationSpec.bpsPercentPricing.
  2. Detect percent-priced QLIs via percentRateForQli (which guards against null / NaN).
  3. Either return null with an unsupported_bps_percent_shape warning, or build a percent product and pipe it through withAvgTransactionSizeTerm.

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

5. Risks & open questions

Read-path rewrite is silent. When a Plaid quote stored with a 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.
Skipped products are non-obvious. A Plaid quote with tiered BPS pricing will import with fewer products than the source. The new 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).
Typecheck note. Author flags pre-existing roundToNearest typing errors in pricingEngineService/monthlyMinimum.spec — unrelated to this PR. The three changed-area test suites pass.