Plaid renewal auto-seed: prior contract → v2 quote

When a renewal opp lands for Plaid Test Org, auto-seed a v2 quote from the prior CPQ contract and light up the "previous price" + "previous usage" columns — no rep button, no flag.

PR dealops#5925 Author @yijunz166 Base main Branch claude/plaid-v2-renewal-autoseed Files 35 Δ +2649 / −179 Supersedes #5901 Scope Dealops 2 · Plaid only

Trigger
Salesforce sync emits opportunity.created / .refreshed. A new listener gates on org + renewal type and dispatches the existing dealops2RenewalInit BullMQ job.
Captures
The job imports the prior CPQ quote and freezes a priorContractSnapshot JSONB column on PricingQuote in the same insert — products, prior minimum, prior billed usage, dates.
Surfaces
quote.get overlays the snapshot onto priorProductsFull only when the DealGroup walk found nothing. New FE cells render Previous Price and a Usage trend glyph.
Preserves
Other orgs, non-renewal types, amendments, and v2-native renewals all hit early returns or the empty-walk guard. Default importers gain no new Salesforce reads.
Listener (Postgres + dispatch)
Job (Salesforce reads)
Persistence (JSONB column)
Read path (quote.get + FE)
Gate / bail

1. Why this exists

v1 (Penguin) parity
The legacy stack already showed reps the customer's prior prices and billed usage on renewals. v2 had no equivalent — renewal quotes opened blank.
DealGroup walk doesn't reach
Plaid's prior contract is a live Salesforce CPQ contract, not a v2 Dealops contract. There is no v2 DealGroup membership to walk, so priorProductsFull came back empty.
No rep button
Previous designs required a rep to click "initialize". This PR removes that step entirely — opening the renewal opp Just Works.

2. End-to-end flow

Step through the lifecycle below. Each step lights up the nodes responsible for it; the rest dim to context.

3. Three fixes, one shape

Fix 1 — Auto-seed
A platform-event listener gates on isPlaidV2OrgName + PLAID_RENEWAL_OPPORTUNITY_TYPES, dedupes on existing quotes, and dispatches dealops2RenewalInit with a deterministic jobId.
Fix 2 — Previous price
A new nullable JSONB column on PricingQuote stores a frozen snapshot at seed time. quote.get overlays it onto priorProductsFull only when the DealGroup walk is empty.
Fix 3 — Previous usage
QLI quantities are write-time placeholders, so the import queries usage_and_mrr__c for the customer's billed actuals over the trailing 6 months and averages per productSpecId.

4. The gate (why Plaid prod is safe)

There are two Plaid orgs in the system. The PR introduces isPlaidV2OrgName specifically because the legacy isPlaidOrgName matches both — and matching prod would seed v2 quotes on an org that's still served by v1.

Org nameSystemisPlaidOrgNameisPlaidV2OrgNameAuto-seed runs?
'Plaid Test Org'Dealops 2 (canary + staging)Yes, on renewal types
'Plaid'Dealops 1 (production)No — gate fails
Every other orgNo — gate fails
The choice of predicate is load-bearing. Using isPlaidOrgName here would dispatch the v2 renewal-init job for prod Plaid on every renewal opp sync.

5. The dispatch — jobId, retries, race guard

The listener does only Postgres reads + a dispatch. Salesforce reads stay inside the BullMQ job, off the request path. Three details in the dispatch call matter:

await _testHooks.dispatch(
  'dealops2RenewalInit',
  { organizationId, userId: opp.userId, opportunityV2Id: opp.id, sfdcOpportunityId: opp.crmId },
  {
    jobId: `dealops2RenewalInit-${opp.id}`,   // deterministic; '-' not ':' (BullMQ reserves :)
    removeOnFail:     { age: 3600 },            // retry on next CRM sync after 1h
    removeOnComplete: { age: 3600 },            // no-op seeds (prior quote missing) also unblock after 1h
  },
);
Deterministic jobId
Concurrent created / refreshed events for the same opp collapse into one job — BullMQ ignores an add with an in-flight or retained jobId.
Separator is -, not :
BullMQ's Job.validateOptions throws "Custom Id cannot contain :" — colons are reserved as its Redis key separator.
1h eviction on both outcomes
removeOnFail lets a transient SF blip retry on the next sync. removeOnComplete covers the no-op prior_contract_quote_not_found path so it isn't permanently deduped for 24h.

6. The snapshot — atomic capture, read-time fallback

The snapshot has to be written in the same insert as the quote. Otherwise a follow-up write could fail and leave the listener's dedupe guard pointing at a snapshot-less quote forever. CpqImportService threads it all the way down to repository.create.

Before — no snapshot path
// quote.get
const info = await getAmendmentInfo(oppId);
// info.priorProductsFull === []
// for Plaid renewals (no v2
// DealGroup to walk)
return { ...info, ... };
Renewal opens with an empty pricing table — no "previous price" column populated.
After — guarded overlay
const raw = await getAmendmentInfo(oppId);
const snap = result.priorContractSnapshot;
const info = applyPriorContractSnapshot(raw, snap);

// Inert if:
//  - snap is null / no products
//  - raw.priorProductsFull non-empty
//    (walk wins → amendments unchanged)
The overlay only activates when the walk was empty and a snapshot exists.

What's in the snapshot

FieldTypeSourceWhy it's there
productsProductInput[]Imported prior CPQ QLIsSurfaced as priorProductsFull → "Previous Price" cells.
minimumCommitmentMinimumCommitmentInputreconstructMinimumCommitmentPrior monthly/annual minimum. (Import produces this — not multiCommitments.)
usageDataForRenewalPriorUsageDataForRenewalusage_and_mrr__c billed actualsTwo 3-month windows of avg monthly billed qty per spec, + true-up.
startDate / endDatestring?Prior CPQ quote headerSurfaced as priorQuoteStartDate / priorQuoteEndDate.

7. Previous usage — billed actuals, not QLIs

Why the snapshot needs its own usage capture rather than reading SBQQ__Quantity__c off the prior quote:

QLI quantity is a placeholder
SBQQ__Quantity__c is a write-time field that's never read back. Every imported product carries volumeFlat: 1.
v1 had a solution
addUsageDataToPricingFlow in utils/penguin/renewals.ts queried usage_and_mrr__c. v2 had no port (G3 in renewalInit PLAN).
Copy, don't share
Per PLAN.md D7, helpers are copied into cpqImport/priorUsage.ts — not imported from utils/penguin/ — so that directory can still be deleted wholesale post-cutover.

The aggregation is deliberately split into pure helpers + a thin IO wrapper, so the date math and averaging are unit-testable without a SalesforceAPI stub:

// trailing 6 full months, anchored to month-ends
computeUsageWindows(now) → { startDate, endDate, threeMonthsBoundary }

// rows split on the boundary; averaged per v2 productSpecId
buildPriorUsageData({ records, codeToSpecId, now }) → {
  productUsageLast3Months: { startDate, endDate, usage, averageMonthlyMinimumTrueUp },
  productUsage3to6Months:  { startDate, endDate, usage, averageMonthlyMinimumTrueUp },
}
Failure semantics: the usage fetch runs before the insert and is un-caught. A Salesforce failure aborts the seed with no quote row left behind — so the listener's dedupe guard stays clean and the next CRM sync retries. Matches v1.

Tier → spec dedupe

One subtle v2 difference from v1: several SF ProductCodes can resolve to the same umbrella spec (tier children under one product). Without care, a single usage record would double-count. The fix counts a record once per resolved spec:

for (const record of records) {
  if (record.quantity__c === null) continue;
  const specIds = new Set<string>();                       // dedupe per record
  for (const code of mapMrrRecordToProductCodes(record)) {
    const specId = codeToSpecId.get(code);
    if (specId) specIds.add(specId);
  }
  for (const specId of specIds) {
    quantitiesBySpec.get(specId) ?? quantitiesBySpec.set(specId, []);
    quantitiesBySpec.get(specId)!.push(record.quantity__c);
  }
}

8. The renewal signal — snapshot ⇒ isRenewal

One read-path subtlety: when the snapshot fallback fires, quote.get also flips isRenewal = true:

// trpc/router/pricingQuote/get.ts
if (hasSnapshotFallback) {
  isRenewal = true;
}

The presence of a snapshot is an authoritative renewal signal — it's only ever written by the renewal-init import. This matters because CRM opp-types like "Auto Renewal" don't literally contain "Renewal" in every comparison v2 makes, and treating the quote as an amendment would clamp product end-dates to the prior contract's end. Renewal = new term, fresh dates.

9. FE — reading the new fields

Everything comes back from the existing trpc.pricingQuotes.get query. No new endpoint, no new request. For non-renewal / non-Plaid quotes the fields come back null / empty, so call sites can read them unconditionally.

Previous price
Reads from priorProductsFull on the get payload. A new helper getPriorPriceDisplayValue flattens any quotePriceFlat shape (flat / tiered / ramped) to a single display currency for the pill; the raw value still goes to TieredBadge so the tier structure renders.
Previous usage
Read off pricingQuote.priorContractSnapshot.usageDataForRenewal. Keyed by productSpecId (not product input id). Numbers are average monthly billed quantity. Absent key ⇒ render 0 / "—".
const snap  = pricingQuote.priorContractSnapshot;   // null unless renewal-seeded
const usage = snap?.usageDataForRenewal;            // null if no usage captured

usage?.productUsageLast3Months;
//   { startDate, endDate,
//     usage: Record<productSpecId, number>,        // avg MONTHLY billed quantity
//     averageMonthlyMinimumTrueUp: number }

const priorAvgQty = usage?.productUsageLast3Months.usage[row.productSpecId];
snap?.minimumCommitment;                             // MinimumCommitmentInput | null

UsageTrendCell — the new visual

A 58×26 SVG trend glyph per product row. State resolution is small and deliberate:

StateWhenGlyph
untrackedBoth windows undefinedEm-dash
naExactly one window knownDashed line, hollow + solid dot
newprior=0, last>0Blue rising line
up / down|Δ%| > 2Green / red sloped line
flat|Δ%| ≤ 2Grey flat line

10. What it doesn't change

11. Risks / rollback

Migration shape. Single additive nullable JSONB column on PricingQuote. No backfill, no lock risk. Rollback = drop the column; existing renewals lose the previous-price overlay and revert to the empty pricing table.
Blast radius. Tightly scoped to 'Plaid Test Org' + renewal types behind early-return gates. The read-time overlay is a guarded fallback that activates only when priorProductsFull.length === 0. All Salesforce reads stay inside the BullMQ job, off the web-request cycle.
Follow-ups (non-blocking). Keep MRR_INFO_TO_PRODUCT_CODES in priorUsage.ts in sync with the v1 copy until utils/penguin/ is deleted. The diff_org_specs CI job fails for unrelated infra reasons (no Redis service in that workflow + empty READ_ONLY_PRODUCTION_URL); it's continue-on-error and needs a workflow-level fix.

12. Test coverage

FileWhat it pins down
plaidRenewalAutoSeedReactor.test.tsGating (incl. prod-Plaid exclusion), dedupe, dispatch payload, colon-free deterministic jobId, both 1h retention overrides, error propagation.
priorUsage.test.tsCode → spec mapping, window math, per-spec averaging incl. umbrella-spec dedupe, MM true-up averaging, IO wrapper (account scoping + date filters, abort on missing AccountId).
CpqImportService.test.tsSnapshot captured in same insert (products + MM + usage); abort-before-insert on usage failure; no extra SF reads for default importers.
applyPriorContractSnapshot.test.tsFallback only fires on empty walk + present snapshot; walk wins when both exist.
RenewalInitService.test.tsPasses capturePriorContractSnapshot: true through to the import.

13. Verify locally

  1. Run migrations — the priorContractSnapshot migration is in this PR.
  2. On Plaid Test Org, create (or re-sync) a renewal-type opp with a prior CPQ contract in Salesforce.
  3. Confirm the quote is auto-seeded with prior products. Pricing table shows previous price (incl. tiered/ramped) and the usage trend.
  4. Inspect priorContractSnapshot on the row, or in the pricingQuotes.get response: products, minimumCommitment, usageDataForRenewal, dates.
  5. Edit the seeded quote — "previous price" does not move.
  6. Isolation: non-Plaid orgs and non-renewal Plaid deals get no auto-seed.