Plaid live-read renewals get the full snapshot
The Plaid renewal live-read path now seeds the prior-contract snapshot — Previous Price, Existing Product tags, and prior usage — so renewal quotes stop pricing as fresh catalog items.
CpqImportService.import() into a shared helper, return it from previewImport(), and carry it through RenewalInitService onto the quote created by pricingQuote.create.
1. Why this exists
This is the follow-up to #6122, which added a live Salesforce read so a Plaid renewal create wouldn't fall through to a blank quote when the auto-seed listener hadn't fired yet. That PR fixed the empty-quote bug but only seeded products: it deliberately skipped the prior-contract snapshot and the prior-usage fetch, leaving the rep with a quote that looked renewal-ish (products pre-selected) but priced like net-new.
- Products pre-selected yes
- Previous Price column missing
- "Existing Product" tags missing
- Volume / sparklines 0 / mo
- Discount approval flow L1/L2/L3
- Products pre-selected yes
- Previous Price column populated
- "Existing Product" tags populated
- Volume / sparklines from billed actuals
- Discount approval flow renewal at prior price
The root cause was structural: CpqImportService.import() assembled the snapshot inline inside its DB write, so anything that called previewImport() instead (the live-read path) had no way to reach that logic.
2. What changes
Refactor: buildPriorContractSnapshot as a shared helper
The snapshot assembly — prior-price products, committed minimum, contract dates, and the fetchPriorUsageData call — moves out of import()'s inline insert payload into a private method. import() behavior is unchanged; previewImport() gains the ability to return a snapshot when capturePriorContractSnapshot is set.
import()const usageDataForRenewal = args.capturePriorContractSnapshot
? await fetchPriorUsageData({ ... })
: null;
await repo.create({
...
...(args.capturePriorContractSnapshot
? { priorContractSnapshot: {
products: built.pricingQuoteInput.products,
multiCommitments: ...,
minimumCommitment: ...,
startDate: ...,
endDate: ...,
usageDataForRenewal,
} }
: {}),
});
const priorContractSnapshot = args.capturePriorContractSnapshot
? await this.buildPriorContractSnapshot(args, built)
: null;
await repo.create({
...
...(priorContractSnapshot ? { priorContractSnapshot } : {}),
});
// previewImport() can now call the same helper:
const priorContractSnapshot = args.capturePriorContractSnapshot
? await this.buildPriorContractSnapshot(args, built)
: undefined;
The snapshot payload
What buildPriorContractSnapshot returns — and now flows end-to-end:
- products reconstructed prior CPQ line items at prior prices
- multiCommitments prior multi-commit structure (if any)
- minimumCommitment prior committed minimum (import pipeline never emits multiCommitments here)
- startDate / endDate prior contract window
- usageDataForRenewal customer's billed actuals from
usage_and_mrr__cviafetchPriorUsageData
Wiring: service boundary → handler
The three layers chain the snapshot through. RenewalInitService.previewRenewalSeed now requests it and re-narrows the unknown-typed preview field at the service boundary. The handler then forwards both pieces onto the repository create call.
previewImport→
previewRenewalSeed→
resolvePlaidRenewalSeedFromLivePriorContract→
PricingQuoteSeed→
repository.create
// RenewalInitService.previewRenewalSeed — now requests the snapshot
const preview = await this.cpqImportService.previewImport({
sfdcOpportunityId: args.sfdcOpportunityId,
opportunityV2Id: opp.id,
sfdcQuoteId: priorQuoteId,
capturePriorContractSnapshot: true, // ← new
});
return {
pricingQuoteInput: preview.pricingQuoteInput as PricingQuoteInput,
priorContractSnapshot:
(preview.priorContractSnapshot as PriorContractSnapshot | undefined) ?? null,
...
};
// pricingQuote/create.ts — live-read path now carries the snapshot
return {
pricingQuoteInput: preview.pricingQuoteInput,
...(preview.priorContractSnapshot
? { priorContractSnapshot: preview.priorContractSnapshot }
: {}),
};
Call-site gate on Plaid org name
A small but pointed change to the create handler: the isPlaidOrgName check moves up to the call site, so the Plaid fallback is now a true no-op (no DB lookups, no log lines) for every other org. The original guard inside resolvePlaidRenewalSeed stays as defense-in-depth, but silently.
if (!seedInput) {
const plaidRenewalSeed = await resolvePlaidRenewalSeed({...});
// ↑ runs for every org, then logs "skipped"
}
if (!seedInput && isPlaidOrgName(ctx.organization.name)) {
const plaidRenewalSeed = await resolvePlaidRenewalSeed({...});
}
// Inner guard is now a silent return.
3. How it works end-to-end
- Rep clicks "Create Quote" on a Plaid renewal opp. The normal seed path (deal-group memberships → existing snapshot) finds nothing — the auto-seed listener hasn't run yet.
- The handler's new gate evaluates
!seedInput && isPlaidOrgName(...)as true and callsresolvePlaidRenewalSeed. - That resolver locates the prior contract quote in Salesforce and calls
RenewalInitService.previewRenewalSeedwithcapturePriorContractSnapshot: true. CpqImportService.previewImportruns the same pipeline asimport(), then callsbuildPriorContractSnapshot— which firesfetchPriorUsageDataagainstusage_and_mrr__cfor billed actuals.- The handler receives both
pricingQuoteInputandpriorContractSnapshotand passes them torepository.createin a single transactional insert. - The quote row is created with the snapshot already persisted — the next "Create New Quote" on this opp uses the fast snapshot path with zero Salesforce reads.
4. Tests
| Suite | What it pins down |
|---|---|
CpqImportService (14 passing) |
Refactor preserves import()'s snapshot persistence — the inline-to-helper move is invisible to callers. |
RenewalInitService (15 passing) |
New asserts: previewRenewalSeed passes capturePriorContractSnapshot: true and returns the snapshot (incl. usageDataForRenewal) for the caller to carry. |
pricingQuote.create Plaid live-read test |
Asserts the full snapshot lands on repository.create's relationsArg.priorContractSnapshot — flipping the previous "snapshot must be undefined" assertion. |
pricingQuote.create regression test (new) |
Non-Plaid org create never calls previewRenewalSeed and never looks up a snapshot — the new call-site gate is exercised directly. |
5. What it doesn't change
CpqImportService.import()behavior — pure refactor; the snapshot it persists is byte-equivalent to before.- Non-Plaid org code paths — the new call-site gate makes Plaid fallback work strictly narrower than before (previously it ran-then-skipped; now it doesn't run at all).
- Auto-seed listener — the listener's dedupe guard still keys off the same snapshot column; this PR just makes more quotes land with that column populated on first write.
- Schema — no Prisma migration.
priorContractSnapshotcolumn already exists from prior work. - tRPC inputs/outputs — no public API surface change.
- Dealops 1 — untouched; all changes are under
apps/server/src/dealops2/and the v2 tRPC create handler.
6. Risks & rollback
fetchPriorUsageData call before the quote insert. Existing posture holds: any Salesforce/import failure degrades to a blank quote rather than failing the create — same try/catch as the rest of resolvePlaidRenewalSeedFromLivePriorContract. Worth watching p95 on Plaid renewal creates after rollout.