Plaid v2 diff harness: end-to-end at last

The replay_pending_input_transform placeholder is gone — the harness can now reconstruct a v2 PricingQuoteInput from v1 SF data and diff the would-write QLI bodies against v1's baseline.

Author mehulshinde PR dealops#5679 Branch dealops-hux Files 3 +/− 850 / 44 Stack ee8 · n5u · cjd Status Open

What it adds

A real v1→v2 input transform in runPlaidDiffHarness.ts, plus a new PlaidWriteBack.buildHarnessDryRunQliPayload entry point that produces would-write QLI bodies without touching SF or DB.

What it unblocks

The ≥1-week clean-window countdown for Plaid v2 writeback rollout. The harness can now produce real diffs on every replayed quote instead of bailing at the placeholder.

What it preserves

Phase 1 SF preprocessing, Phase 3 composite POST, the canary feature flag for live writes, and the --smoke-replay mode. All untouched.

v1 prod (read-only baseline)
Canary org (v2 replay target)
ee8 reconstruction (new wiring)
Diff output

1. Why this exists

The diff harness shipped in #5603, but it had no way to turn a v1 PricingFlow into a v2 PricingQuoteInput. Every replayed quote returned replay_pending_input_transform with a v1-baseline-only record — useful for spot-checks, useless as a clean-window signal.

Blocking deps (all landed)
  • dealops-ee8 #5648 — v2 CPQ Import reconstruction
  • dealops-n5u #5654 — renewals + amendments port
  • dealops-cjd #5568 — supporting infra
  • canary install #5604 — seeded canary org
What "clean window" means

≥1 week of replayed quotes where v2's would-write QLI bodies match v1's actual SF writes within the documented diff tolerances. This PR is the gate: no transform, no countdown.

2. The pipeline, end to end

Per-quote flow. Step through to see which stages run, which are skipped, and where the org boundary sits.

3. The new public method: buildHarnessDryRunQliPayload

Lives on PlaidWriteBack. Runs Phase 2 prep (resolvePlaidProductReferences) and Phase 2 build (buildPlaidCompositeRequest) against the canary org, then filters the composite sub-requests down to just the QLI create bodies.

What it does
  • Loads pricingSpec for the canary org
  • Resolves product/pricebook references via live SF reads on the canary's Test SF instance
  • Builds a minimal in-memory PricingQuote with placeholder ids
  • Best-effort sales-rep resolution (omits the field if no userId)
  • Filters composite sub-requests to POST  →  QUOTE_LINE_SOBJECT_PATH
What it deliberately skips
  • Phase 0: DB PricingQuote lookup
  • Phase 1: Opportunity preprocessing (SF deletes)
  • Phase 3: Composite/graph POST to SF
  • Feature flag bypassed — the harness needs to see v2 output regardless of canary flag state
  • Opportunity PATCH and quote-create sub-requests (filtered out of the return value)

Safety contract

No writes anywhere. Ampersand/SF calls go through crmService's read-only path (queryPricebookEntriesRaw against the canary Test SF org). Prisma access is read-only: pricingSpec, salesforceProduct, pricebookEntries. The placeholder opportunity id (__harness_dry_run_opportunity__) is never sent to SF because Phase 3 doesn't run.

4. Two design calls worth flagging

Both bypass paths in reconstructV2InputForHarness exist for the same root cause: the canary's SF connection points at a different SF org than where v1's prod quotes live.

Decision 1 — bypass CpqImportService.previewImport()

Canary install e846ccc7-… connects to the Plaid Test SF org. v1 prod quotes live in a different SF org reachable only via v1 install 4045adc1-….

Passing a v1 prod quote id to previewImport would fail — the quote doesn't exist in the canary's SF instance.

Fix: read SF data through the v1 connection, then call ee8's reconstruction functions directly with the canary's specs.

Decision 2 — name-based pricebook resolver

ee8's reconstructPlaidQuoteMetadata resolves pricebook by crmPricebookId, which is per-SF-org. The prod pricebook id will never match a canary pricebook seeded against the Plaid Test Org.

Fix: resolveCanaryPricebookId uses v1's additionalData.pricebook ('CPQ' / 'Partnership') + the quote currency to look up by name ('CPQ USD', 'Partnership EUR', …) with a prefix fallback.

5. The silent bug caught during self-review

QLI cast was lying. SBQQ_QUOTELINE_FIELDS_TO_READ was missing three fields that ee8's SalesforceQuoteLineItem shape requires. The cast v1Rows as unknown as SalesforceQuoteLineItem[] silenced the gap entirely — at runtime, those fields would be undefined, every product match would fail (no SBQQ__ProductCode__c), and every reconstructed quote would have products: []. Blank v2 payloads, useless reports, only caught on a Render smoke run.
Missing fields
  • SBQQ__ProductCode__c — matched against v2 productSpec.productCode
  • SBCF_Net_Unit_Price__c — primary price source for ProductInput
  • SBQQ__TotalDiscountAmount__c — fallback price math
The fix: compile-time guard

A new _QliCoverageCheck type-level assertion derives the set of non-optional fields from ee8's SalesforceQuoteLineItem and requires that SBQQ_QUOTELINE_FIELDS_TO_READ contains all of them. If ee8 adds a required field and the SOQL SELECT doesn't pick it up, this file fails to compile.

Show the type assertion
type _RequiredQliFields = keyof {
  [K in keyof SalesforceQuoteLineItem as undefined extends SalesforceQuoteLineItem[K]
    ? never
    : K]: true;
};
type _ReadFields = (typeof SBQQ_QUOTELINE_FIELDS_TO_READ)[number];
type _QliCoverageCheck = _RequiredQliFields extends _ReadFields
  ? true
  : `Missing ee8-required QLI field(s) in SBQQ_QUOTELINE_FIELDS_TO_READ`;
const _qliCoverageCheck: _QliCoverageCheck = true;

6. Date arithmetic: matching ee8 exactly

End-date computation lives in two places now (harness + ee8's reconstructPlaidQuoteMetadata), so they must produce identical output for the diff to be meaningful. Both use dayjs.utc — the month-end edge case is the load-bearing reason.

InputNative Date.setMonthdayjs.utc (used by both)
2026-01-01 + 12mo − 1d 2026-12-31 2026-12-31
2026-01-31 + 1mo − 1d 2026-03-02 (rolls into March) 2026-02-27 ✓ (clamps to Feb 28, then −1d)

7. Test coverage

19 unit tests in the new runPlaidDiffHarness.test.ts, all running under Mocha (matches the new-tests-default-to-Mocha convention).

resolveCanaryPricebookId · 8 tests
  • Exact {type} {currency} match
  • Currency disambiguation between siblings
  • Type disambiguation between siblings
  • Prefix fallback when exact name is missing
  • Error: no pricebook matches type at all
  • Error: additionalData null / missing / non-string
reconstructV2InputForHarness · 11 tests
  • Validation: unsupported currency, missing/negative term, invalid date
  • Metadata pass-through: currency, term, pricebook id, CRM id, null start date
  • End-date arithmetic: clean boundary + Jan 31 month-end clamp
Out of scope (intentional): unit tests for buildHarnessDryRunQliPayload. Per scoping doc §6, integration-tested via post-merge smoke run. Also out of scope: a reconstructPlaidQuoteMetadata refactor to accept a pricebook-resolver strategy — filed as a follow-up so the harness can drop its inline metadata construction.

8. What it doesn't change

9. Risks & rollback

Risk: org-boundary confusion. The two-SF-org topology (v1 prod ↔ canary Test) is the source of both bypass decisions. Anyone changing the harness needs to keep that boundary clear — feeding a v1 prod quote id into a canary-connection call will silently fail to find the record. The inline comments call this out at every cross-boundary read.
Rollback is trivial. The harness is an offline script — no prod traffic depends on it. If a bug surfaces, revert the PR; the worst case is the clean-window countdown doesn't start yet. buildHarnessDryRunQliPayload is a new method with no callers outside the harness, so removing it can't regress production writeback.

10. Open questions