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.

PR dealops#5654 Author @mehulshinde Branch mehul/plaid-v2-n5u-renewals Bead dealops-n5u + n5u.1 Files 22 +/− +1894 / −52 Stacked on #5648 Status Open

What it adds
A new 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.
What it fixes
Closes ee8's G2 gap: renewedSubscriptionId now threads through productInputSchema and per-tier into tieredCurrencySchema, and PlaidWriteBack emits it at all three QLI sites instead of hardcoding null.
Why now
Renewals dominate Plaid's quote volume. Without n5u, every renewal lands in v2 as an empty quote the rep hand-rebuilds. ee8 alone only covers the import-from-known-quote-id path.
What it isn't
Not a full 1,470-LOC port. Recon found ee8 + a28 + dealgroup already cover ~80% of v1 renewals.ts. This PR is the remaining glue: prior-quote discovery, opp-driven entry, G2 surface.
tRPC / Job entry
RenewalInitService (new)
findPriorContractQuote (new)
CpqImportService (ee8, existing)
PlaidWriteBack (G2 patch)

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.

Recon resolved the scope. The bead was originally filed as "1,470 LOC port" but two parallel beads (a28 monthly-minimum math, ee8 CPQ import) and existing dealgroup work had already covered most of it. The remaining concrete gaps were three: prior-contract discovery, an opportunity-driven entry point, and the 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.

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.

PhaseConcernPrimary 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.

Before (ee8)
// 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.

After (n5u.1)
// 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

Flat product
Single QLI, single value. Copy qli.SBQQ__RenewedSubscription__c directly onto the ProductInput.
Tiered product
Each tier gets its own field. PlaidWriteBack falls back tier → product → null at emit time, so per-tier null safely inherits the umbrella value.
Segmented product
One product, many per-segment QLIs. Take the first non-null. If segments disagree, take first non-null and emit 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,
Regression guard. A new test reads 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

1. Lookup
opportunityRepository.findByCrmId(sfdcOpportunityId, organizationId). Defense-in-depth check re-asserts opp.organizationId === args.organizationId at the service boundary.
2. Type gate
Reads opp.opportunityV2Data.type via extractOpportunityType. Must be one of Plaid's four: Auto Renewal, Manual Renewal, Renewal, Early Renewal.
3. Discover
Calls findPriorContractQuote. Null result returns a warning-bearing result object (no throw) — caller can choose to fall back to empty quote.
4. Delegate
Hands the discovered quote id to 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:

  1. Validates SFDC id shape via Zod regex (/^[a-zA-Z0-9]{15,18}$/) with trim.
  2. Gates on isPlaidOrgName(ctx.organization.name)FORBIDDEN.
  3. Eagerly fast-fails non-renewal opportunity types so the caller gets a synchronous BAD_REQUEST instead 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

+apps/server/src/dealops2/services/renewalInit/RenewalInitService.ts+152
+apps/server/src/dealops2/services/renewalInit/findPriorContractQuote.ts+36
+apps/server/src/dealops2/services/renewalInit/constants.ts+13
+apps/server/src/dealops2/services/renewalInit/index.ts+3
+apps/server/src/dealops2/services/renewalInit/PLAN.md+346
+apps/server/src/dealops2/services/renewalInit/__tests__/RenewalInitService.test.ts+236
+apps/server/src/dealops2/services/renewalInit/__tests__/findPriorContractQuote.test.ts+147
+apps/server/src/trpc/router/dealops2RenewalInit/start.ts+107
+apps/server/src/trpc/router/dealops2RenewalInit/_router.ts+14
+apps/server/src/trpc/router/dealops2RenewalInit/__tests__/start.test.ts+261
+apps/server/src/jobs/definitions/dealops2RenewalInit.ts+95
~apps/server/src/dealops2/services/cpqImport/reconstructProductInputs.ts+31 −16
~apps/server/src/dealops2/services/cpqImport/__tests__/reconstructProductInputs.test.ts+119 −5
~apps/server/src/dealops2/services/cpqImport/types.ts+5 −1
~apps/server/src/dealops2/services/crm/writeback/implementations/PlaidWriteBack.ts+8 −3
~apps/server/src/dealops2/services/crm/writeback/implementations/PlaidWriteBack.test.ts+218 −27
~packages/types/v2/pricingQuoteInput.ts+2
~packages/types/v2/pricing.ts+2
~apps/server/src/jobs/registry.ts+2
~apps/server/src/trpc/router/_app.ts+2

9. What this PR doesn't change

10. Risks & open follow-ups

Engagement hierarchy gap (tracked as 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.
No real-data integration coverage yet. Plaid Test Org has no renewal fixtures today. Real-data parity is gated on dealops-hux capture work. Unit tests use synthetic fixtures; we cannot prove v1↔v2 byte equivalence on renewal-driven imports until hux ships.
Filed follow-ups. 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).