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.
opportunity.created / .refreshed. A new listener gates on org + renewal type and dispatches the existing dealops2RenewalInit BullMQ job.
priorContractSnapshot JSONB column on PricingQuote in the same insert — products, prior minimum, prior billed usage, dates.
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.
quote.get + FE)1. Why this exists
priorProductsFull came back empty.
2. End-to-end flow
Step through the lifecycle below. Each step lights up the nodes responsible for it; the rest dim to context.
opportunity.created or opportunity.refreshed. The new plaidRenewalAutoSeedReactor subscribes to both — created covers the first open; refreshed covers later CRM syncs in case type/products weren't set yet at creation.
3. Three fixes, one shape
isPlaidV2OrgName + PLAID_RENEWAL_OPPORTUNITY_TYPES, dedupes on existing quotes, and dispatches dealops2RenewalInit with a deterministic jobId.
PricingQuote stores a frozen snapshot at seed time. quote.get overlays it onto priorProductsFull only when the DealGroup walk is empty.
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 name | System | isPlaidOrgName | isPlaidV2OrgName | Auto-seed runs? |
|---|---|---|---|---|
'Plaid Test Org' | Dealops 2 (canary + staging) | ✓ | ✓ | Yes, on renewal types |
'Plaid' | Dealops 1 (production) | ✓ | ✗ | No — gate fails |
| Every other org | — | ✗ | ✗ | No — gate fails |
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
},
);
created / refreshed events for the same opp collapse into one job — BullMQ ignores an add with an in-flight or retained jobId.
-, not :Job.validateOptions throws "Custom Id cannot contain :" — colons are reserved as its Redis key separator.
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.
// 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.
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
| Field | Type | Source | Why it's there |
|---|---|---|---|
products | ProductInput[] | Imported prior CPQ QLIs | Surfaced as priorProductsFull → "Previous Price" cells. |
minimumCommitment | MinimumCommitmentInput | reconstructMinimumCommitment | Prior monthly/annual minimum. (Import produces this — not multiCommitments.) |
usageDataForRenewal | PriorUsageDataForRenewal | usage_and_mrr__c billed actuals | Two 3-month windows of avg monthly billed qty per spec, + true-up. |
startDate / endDate | string? | Prior CPQ quote header | Surfaced 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:
SBQQ__Quantity__c is a write-time field that's never read back. Every imported product carries volumeFlat: 1.
addUsageDataToPricingFlow in utils/penguin/renewals.ts queried usage_and_mrr__c. v2 had no port (G3 in renewalInit PLAN).
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 },
}
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.
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.
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:
| State | When | Glyph |
|---|---|---|
untracked | Both windows undefined | Em-dash |
na | Exactly one window known | Dashed line, hollow + solid dot |
new | prior=0, last>0 | Blue rising line |
up / down | |Δ%| > 2 | Green / red sloped line |
flat | |Δ%| ≤ 2 | Grey flat line |
10. What it doesn't change
- Production
'Plaid'org (still on v1) — gate fails, no dispatch. - Every other org — gate fails on org name.
- Non-renewal Plaid opps (New Business, etc.) — gate fails on type.
- Amendments and expansions — DealGroup walk populates
priorProductsFull, so the snapshot overlay never activates. - v2-native engagement renewals — same as above; the walk wins.
- Default CPQ importers (round-trip diff harness, tRPC import) —
capturePriorContractSnapshotdefaults false; no new Salesforce reads. - The rep's editable
quoteInput— snapshot lives in its own column, so "previous price" doesn't move when the rep edits the renewal. - No new tRPC endpoint, no new client request, no feature flag.
11. Risks / rollback
PricingQuote. No backfill, no lock risk. Rollback = drop the column; existing renewals lose the previous-price overlay and revert to the empty pricing table.
'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.
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
| File | What it pins down |
|---|---|
plaidRenewalAutoSeedReactor.test.ts | Gating (incl. prod-Plaid exclusion), dedupe, dispatch payload, colon-free deterministic jobId, both 1h retention overrides, error propagation. |
priorUsage.test.ts | Code → 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.ts | Snapshot captured in same insert (products + MM + usage); abort-before-insert on usage failure; no extra SF reads for default importers. |
applyPriorContractSnapshot.test.ts | Fallback only fires on empty walk + present snapshot; walk wins when both exist. |
RenewalInitService.test.ts | Passes capturePriorContractSnapshot: true through to the import. |
13. Verify locally
- Run migrations — the
priorContractSnapshotmigration is in this PR. - On Plaid Test Org, create (or re-sync) a renewal-type opp with a prior CPQ contract in Salesforce.
- Confirm the quote is auto-seeded with prior products. Pricing table shows previous price (incl. tiered/ramped) and the usage trend.
- Inspect
priorContractSnapshoton the row, or in thepricingQuotes.getresponse:products,minimumCommitment,usageDataForRenewal, dates. - Edit the seeded quote — "previous price" does not move.
- Isolation: non-Plaid orgs and non-renewal Plaid deals get no auto-seed.