Plaid v2 renewals: opportunity-driven init
Port v1's renewal-from-opportunity seeding to Dealops 2 by composing the new CpqImportService with a small SOQL helper, and close the lingering G2 gap so renewedSubscriptionId survives the round trip.
RenewalInitService that, given an SFDC opportunity, finds the prior contract's auto-generated SF quote and delegates to ee8's CpqImportService. Wrapped in a tRPC mutation + BullMQ job, both Plaid-org-gated.
renewedSubscriptionId now threads through productInputSchema and per-tier into tieredCurrencySchema, and PlaidWriteBack emits it at all three QLI sites instead of hardcoding null.
renewals.ts. This PR is the remaining glue: prior-quote discovery, opp-driven entry, G2 surface.
1. Why this exists
Plaid's v1→v2 cutover (EPIC dealops-xq9) needs renewals working end-to-end. ee8 (PR #5648) shipped the generic CPQ importer — give it an sfdcQuoteId and it materializes a v2 PricingQuote. But the actual user flow is opportunity-driven: a rep opens a renewal opportunity in Salesforce, and the system needs to discover which prior quote to import from.
v1 does this in apps/server/src/utils/penguin/renewals.ts via initializePricingFlowFromRenewalOpportunity + getOldestQuoteId. n5u ports just those two pieces, then composes them with ee8.
renewedSubscriptionId surface. See renewalInit/PLAN.md for the full bucket-by-bucket map.
2. End-to-end flow
Step through the request path. The orchestrator does almost nothing on its own — its job is to compose three existing pieces with one small new SOQL lookup.
dealops2RenewalInit.start mutation validates the SFDC opportunity id shape, gates on Plaid org name, and eagerly fast-fails non-renewal opportunity types so callers see a 400 instead of an async BullMQ failure.
3. The four phases, mapped to files
The PR is structured as four sequenced phases per PLAN.md. Each was written test-first, each is independently shippable.
| Phase | Concern | Primary files |
|---|---|---|
| P0 | Extend schemas with renewedSubscriptionId; populate it through ee8 reconstructor; thread it through PlaidWriteBack QLI emit sites. Closes n5u.1. |
packages/types/v2/pricingQuoteInput.ts,
packages/types/v2/pricing.ts,
cpqImport/reconstructProductInputs.ts,
PlaidWriteBack.ts
|
| P1 | SOQL helper to discover the prior contract's auto-generated SF quote (verbatim port of v1's getOldestQuoteId). |
renewalInit/findPriorContractQuote.ts
|
| P2 | Orchestrator: validate renewal type → discover prior quote → delegate to CpqImportService. |
renewalInit/RenewalInitService.ts,
renewalInit/constants.ts
|
| P3 | tRPC mutation + BullMQ job mirror of dealops2CpqImport, Plaid-org-gated. |
trpc/router/dealops2RenewalInit/*,
jobs/definitions/dealops2RenewalInit.ts
|
4. P0: the renewedSubscriptionId trail
This is the most subtle phase. v1 carries SBQQ__RenewedSubscription__c on every QLI; v2's ProductInput had no slot for it, so ee8 was dropping it on the floor with a renewed_subscription_id_dropped warning. Three things needed to change in lockstep.
// reconstructProductInputs.ts
for (const { qli } of rows) {
if (qli.SBQQ__RenewedSubscription__c) {
warnings.push({
code: 'renewed_subscription_id_dropped',
message: `QLI ${qli.Id} carried ...
which v2 ProductInput cannot store today`,
});
}
}
Field detected, warning emitted, value thrown away.
// flat
return {
...baseProduct,
renewedSubscriptionId:
qli.SBQQ__RenewedSubscription__c ?? undefined,
quotePriceFlat: numberCurrency(...),
};
// tier — per-tier!
return {
min: ..., max: ..., value: ...,
renewedSubscriptionId:
t.qli.SBQQ__RenewedSubscription__c ?? undefined,
};
Value populated on flat/tier/segmented shapes; warning code retired.
The three QLI shapes, three resolution rules
qli.SBQQ__RenewedSubscription__c directly onto the ProductInput.
tier → product → null at emit time, so per-tier null safely inherits the umbrella value.
renewed_subscription_id_segment_mismatch.
PlaidWriteBack: three former null hardcodes, now fed from input
// Flat QLI site (was: renewedSubscriptionId: null)
renewedSubscriptionId: product.renewedSubscriptionId ?? null,
// Per-tier QLI site — tier wins, falls back to product
renewedSubscriptionId:
(tieredSource.kind === 'currency'
? tieredSource.tiers[tierIndex]?.renewedSubscriptionId
: undefined) ??
product.renewedSubscriptionId ??
null,
// Segmented QLI site — single product-level value, propagates to all segments
renewedSubscriptionId: product.renewedSubscriptionId ?? null,
PlaidWriteBack.ts from disk and asserts the literal string renewedSubscriptionId: null no longer appears anywhere. If a future merge adds a 4th QLI emit site and forgets to thread the field through, this test will fail with an explanatory message pointing at the bead.
5. P1: findPriorContractQuote, the SOQL one-liner
A faithful 36-line port of v1's getOldestQuoteId. Same SOQL string verbatim, same LIMIT 1 ORDER BY CreatedDate ASC semantics.
const SFDC_ID_REGEX = /^[a-zA-Z0-9]{15,18}$/;
export async function findPriorContractQuote({
sfdcOpportunityId,
salesforceApi,
}): Promise<string | null> {
if (!SFDC_ID_REGEX.test(sfdcOpportunityId)) {
throw new Error('findPriorContractQuote: invalid sfdcOpportunityId shape');
}
const soql = `SELECT Id FROM SBQQ__Quote__c `
+ `WHERE SBQQ__Opportunity2__c='${sfdcOpportunityId}' `
+ `ORDER BY CreatedDate ASC LIMIT 1`;
const result = await salesforceApi.query(soql);
const records = result.valueOrDie().records;
return (!records || records.length === 0) ? null : records[0].Id;
}
Input validation is upfront: malformed ids throw before any SF call. No prior quote returns null, not an error — the orchestrator decides what that means.
6. P2: RenewalInitService, the orchestrator
opportunityRepository.findByCrmId(sfdcOpportunityId, organizationId). Defense-in-depth check re-asserts opp.organizationId === args.organizationId at the service boundary.
opp.opportunityV2Data.type via extractOpportunityType. Must be one of Plaid's four: Auto Renewal, Manual Renewal, Renewal, Early Renewal.
findPriorContractQuote. Null result returns a warning-bearing result object (no throw) — caller can choose to fall back to empty quote.
CpqImportService.import(). Forwards ee8's warnings into the result unchanged.
Why a Plaid-specific renewal-type set?
v2 already has a generic isOpportunityTypeRenewal in dealgroup/getAmendmentInfo.ts — but it only matches the literal string 'Renewal'. Plaid uses four variants in production CRM data. Rather than expanding the generic predicate (and risking false positives for other orgs), n5u introduces a decoupled constant:
// renewalInit/constants.ts
export const PLAID_RENEWAL_OPPORTUNITY_TYPES: readonly string[] = [
'Auto Renewal',
'Manual Renewal',
'Renewal',
'Early Renewal',
] as const;
The PLAN notes this is hardcoded for now; a future cleanup could move it into per-org spec data.
7. P3: tRPC + BullMQ wiring
Mirrors dealops2CpqImport 1:1 with one schema simplification — the caller doesn't pass a quote id, only the opportunity id. The mutation does three things before dispatch:
- Validates SFDC id shape via Zod regex (
/^[a-zA-Z0-9]{15,18}$/) with trim. - Gates on
isPlaidOrgName(ctx.organization.name)→FORBIDDEN. - Eagerly fast-fails non-renewal opportunity types so the caller gets a synchronous
BAD_REQUESTinstead of an async BullMQ failure. (Per a code review comment — duplication with the service is intentional.)
The job handler then re-checks Plaid org membership and re-validates the opportunity exists in the org before constructing a fresh RenewalInitService per request.
8. Files changed
9. What this PR doesn't change
- Existing v1 renewal path.
utils/penguin/renewals.tsis untouched and stays the production path until cutover completes. - Other orgs. Strict Plaid gating in both the tRPC mutation and the BullMQ job. Non-Plaid orgs get
FORBIDDENwith no side effects. - Engagement contract chain.
RenewalInitServicedoes NOT callEngagementContractService.createRenewalContract— intentionally deferred ton5u.4; see Risks below. - Approval levels. Handled separately by RFC #5268. v2 computes them independently of the seed path.
- Idempotency / dedupe. Per D5, matches v1: every call creates a fresh
PricingQuote. UI is responsible for click-locking. - BullMQ job-handler tests. Matches ee8's zero-coverage pattern; the job is a thin wrapper. Both jobs to be backfilled together in a separate bead.
10. Risks & open follow-ups
n5u.4). When Plaid eventually enables isEngagementContractModelEnabled, quotes created by this service will land directly on OpportunityV2 and won't participate in DealGroup invariant enforcement. There's also no PlaidOpportunityEnrichment.ts equivalent to the Langchain CRM-webhook path from #5620. This is intentional: Plaid is mid-cutover and prior contracts still live in v1, so the engagement chain has no data to walk yet. Integration options when cutover completes: (1) flag-check inside RenewalInitService calling createRenewalContract before delegating, or (2) build PlaidOpportunityEnrichment.ts and shift the trigger to CRM-webhook-driven.
dealops-hux capture work. Unit tests use synthetic fixtures; we cannot prove v1↔v2 byte equivalence on renewal-driven imports until hux ships.
n5u.3 covers the additionalData long tail (country, growthPlanFeedback*, isCryptoOverride, usageDataForRenewal) — punted with ee8's reasoning (no clear v2 home, soon-to-be-replaced surface).
Trail of references
Bead dealops-n5u · closes dealops-n5u.1 · stacked on #5648 · follow-up dealops-n5u.3 / dealops-n5u.4 · unblocks dealops-hux · EPIC dealops-xq9 (Plaid v1→v2 migration).