Plaid v2 writeback: three independent canary parity fixes
One bundle, three logically independent diffs uncovered by the same canary-run-15 forensic trace — approval ceilings, full parent bodies, and a soft-skip that stops one bad product from blanking entire quotes.
v2Payloads.
.ts in PlaidWriteBack.ts, and three new Mocha test files. No schema migrations, no API surface changes.
SBCF_Approval_Level__c: null omission. The MM-omission regression pin stays green.
PricebookEntry is missing. Both have follow-up beads.
1. Why this exists
Canary-run-15 surfaced a cluster of v1↔v2 writeback diffs on real Plaid quotes. A single forensic trace identified four independent root causes that all live in (or feed) PlaidWriteBack.ts. Each is logically separable; they're bundled because they share a touch surface and were uncovered together.
| Symptom on canary | Root cause | Fix area |
|---|---|---|
| Every Plaid product capped at approval level 1, regardless of price | l2price/l3price/l4price defined in registry but not declared on the spec — engine never evaluates them |
FA1 |
| Bundle umbrella QLI emits 5 fields vs v1's ~19 named + extraFields | emitParentIfNeeded writes a skeletal parent body; parent SF row was never plumbed to the build phase |
FA2 |
| 11 of 14 failed quotes blanked entirely on canary; all hit IDM-AD | Resolver throws PLAID_PIPELINE_PRICEBOOK_ENTRY_NOT_FOUND for one product, losing the whole v2Payload |
FA3a |
| Tier-child QLIs (TRR-T1/T2, INT-T1/T2/T3) dropped with "no v2 productSpec" | additionalCrmProductIds schema field exists and is consumed, but zero Plaid specs populated it |
FA3b |
2. The four fixes at a glance
FA1Activate L2/L3/L4 approval-price variables. Pure config edit to pricingSpec.json.
[
{ "name": "l1price" }
]
Variables exist identically in the registry via makeApprovalPriceVariable, but the evaluation engine only walks variables declared on the spec.
[
{ "name": "l1price" },
{ "name": "l2price" },
{ "name": "l3price" },
{ "name": "l4price" }
]
Engine now emits all four ceilings into pricingEngineSummary.byProduct[*].variables; computePlaidApprovalLevel's rc>0 guard now sees real values.
Sufficient for ~96 products with full L1..L4 datasheet coverage. Six L1-only SKUs still need a v1 data backfill — see deferred items.
FA2Emit full v1-parity parent-body field set. Adds 14 fields to the bundle umbrella QLI plus an extraFields spread, and plumbs the parent SF row to the build phase.
Provenance of each new field
| Field | Source | Notes |
|---|---|---|
SBCF_Approval_Level__c | Hardcoded 0 | v1's approvalLevel ?? 0 with undefined — never routed through resolver. Independent of FA1. |
SBQQ__OriginalPrice__c | Parent SF row UnitPrice | Not the child's listPriceOverride — they differ on real umbrellas. |
CurrencyIsoCode | Parent SF row, else quote target | Falls back to pricingQuote.input.targetCurrency. |
SBQQ__Description__c, Order_Form_Product_Name__c | Parent SF row | Description only emitted if non-empty. |
8× SBQQ__* subscription fields | Hardcoded constants | Match v1: SubscriptionType: "Renewable", BillingType: "Monthly Usage", etc. |
...extraFields | Parent SF row | ~10–15 keys on real umbrellas. SBCF_Net_Unit_Price__c stripped (non-createable formula). |
FA3aSoft-skip missing pricebook entries. Three throw sites converted to logPagingError + continue, mirroring the existing tier-child skip pattern at PlaidWriteBack.ts:1130-1163.
if (activeEntries.length === 0) {
throw new CrmWriteBackError(
`... no active pricebook entries ...`,
'PLAID_PIPELINE_PRICEBOOK_ENTRY_NOT_FOUND',
);
}
One bad product → entire v2Payload dies. On canary-run-15 this was the dominant failure mode (11/14 quotes).
if (activeEntries.length === 0) {
errorNotificationService.logPagingError(
`... skipping product ${product.id}`,
);
skippedProductIds.add(product.id);
continue;
}
Resolver returns a new skippedProductIds: Set<string>. Build-phase guards at ~:2326 and ~:2443 drive the per-product skip via map-absence.
PricebookEntry is actually missing/inactive in the canary org, or whether queryPricebookEntriesRaw has a filter bug, still needs a SOQL probe. The soft-skip keeps the pipeline alive while that's investigated.
FA3bPopulate additionalCrmProductIds on TRR + ITH specs. Pure JSON edit; the schema field and its consumers (matchSfProductToProductSpec.ts, Product model) were already in place.
// Transactions Refresh
"additionalCrmProductIds": ["01t1Q0000088VwCQAU", "01t1Q0000088VwDQAU"]
// Investments T&H
"additionalCrmProductIds": [
"01t1Q000007UFPFQA4",
"01t1Q000007UFStQAO",
"01t1Q000007UFTLQA4"
]
Closes 5 of 6 tier-suffix unmatched_product_code warnings. The remaining warning ("Spec resolved by 2 flat QLIs; keeping first only") is intentional NO-ACTION pending a v1↔v2 DB compare.
3. How FA2 wires the parent body together
The parent-body field set is the largest single change. It requires plumbing data that wasn't previously in scope at the emitParentIfNeeded call site.
Resolver side — only pay the cost when needed
if (parentProduct2IdsToResolve.size > 0) {
const parentSfRows = await this.loadSalesforceProductsByProduct2Ids({
organizationId,
product2Ids: Array.from(parentProduct2IdsToResolve),
quoteCurrency,
});
for (const [product2Id, row] of parentSfRows) {
parentSfRowsBySfProductId.set(product2Id, {
Product2Id: row.Product2Id,
UnitPrice: row.UnitPrice,
CurrencyIsoCode: row.CurrencyIsoCode,
Description: row.Description,
extraFields: row.extraFields,
});
}
}
The size > 0 guard means non-bundle quotes pay zero extra DB cost. The map is keyed by parent SF Product2Id — same key as the existing parentPricebookEntryIdBySfProductId.
Build side — spread order matters
body: {
// Hardcoded defaults FIRST so extraFields can override on collision
// (mirrors v1's constructPenguinQuoteLineItemData spread order).
SBQQ__PriceEditable__c: true,
SBQQ__SubscriptionType__c: 'Renewable',
SBQQ__BillingType__c: 'Monthly Usage',
// ... 5 more constants
...safeParentExtraFields,
// Structural fields LAST so they always win
SBQQ__Quote__c: quoteRefPlaceholder,
SBQQ__Product__c: parentSfProductId,
SBQQ__ListPrice__c: listPriceOverride,
SBQQ__OriginalPrice__c: parentSfRow?.UnitPrice ?? 0,
CurrencyIsoCode: parentSfRow?.CurrencyIsoCode ?? pricingQuote.input.targetCurrency,
SBCF_Approval_Level__c: 0, // hardcoded, not via resolver
// ...
}
4. What this PR explicitly does NOT change
- Segment fan-out branch (lines ~2030-2033) keeps its existing
SBCF_Approval_Level__c: nullomission — intentional v1 parity from a recent PR. - Monthly-minimum branch (lines ~2246-2251) keeps the same omission. The
PlaidWriteBack.mm.test.tsregression pin stays green. - Approval resolver math.
computePlaidApprovalLevel+resolveApprovalPriceare unchanged; v1'scalculateApprovalLevelForFixedOrTierwas already faithfully ported. - API surface. No tRPC route changes, no schema migrations.
- Existing callers of
buildPlaidCompositeRequest. The newparentSfRowsBySfProductIdparam is optional with an empty-Map default; existing tests work unchanged.
5. Tests
productShared.variables) + FA3b additionalCrmProductIds pin for TRR and ITH specs. 3 tests .skip() with TODOs for the deferred L1-only product backfill.
SBCF_Approval_Level__c: 0 literally pinned (not via resolver), all 14 named fields literal-pinned, extraFields spread verified, SBQQ__RequiredBy__c asserted absent on parent.
skippedProductIds, no throw, paging error logged with the skipped product id, good products still emit QLIs.
REFERENCES_KEYS contract pin to include the two new resolver outputs (parentSfRowsBySfProductId is a build param; skippedProductIds is resolver-output-only).
pnpm run test:mocha --grep "020.25" from apps/server → 16 passing, 3 pending, 0 failing. Full suite has 9 failures that pre-exist on origin/main (Phase A managedBy schema work in #5775 landed without a matching writebackSpec.json update; verified by checking out clean main). Same applies to pnpm run typecheck.
6. Intentionally deferred
FA1 secondary data backfill — 6 L1-only products (Additional History / Audit Copy / PDF Copy of Assets, in 1-Acct and 5-Accts variants).
Each affected SKU has two v1 Product rows in prod Plaid org 0ea42205-b945-46fc-af48-7d4c838edcfa:
- One with a
monthlyMinimumTiers20-band ladder (proper L1-L4) - One with
nonTieredPricingflat L1=0.99 only
The existing v2 L1 entry shape matches the second; canary bN19N: SF=4 evidence implies v1 production walks the first. Unblocking requires tracing which v1 row v1's production resolver actually uses at runtime. Backfill script + investigation plan filed locally.
FA3a SF SOQL probe — determine whether IDM-AD's PricebookEntry is genuinely missing/inactive in the canary org, vs. queryPricebookEntriesRaw having a filter bug. The soft-skip prevents the crash; root-cause fix lands separately.
7. Reviewer attention points
extraFields spread; structural fields AFTER. This mirrors v1's constructPenguinQuoteLineItemData precedence. Reordering will silently change emitted output.
resolvePlaidApprovalLevelForQli, even when it's tempting after FA1 lands. v1 hardcodes 0 on parents; the resolver is never called.
const { SBCF_Net_Unit_Price__c: _omit, ...safe } = extraFields ?? {}) is load-bearing — SF hard-rejects inserts carrying this non-createable formula field.
parentSfRowsBySfProductId is listed (it's a build param). skippedProductIds is intentionally NOT — it's resolver-output-only and rides the spread as a harmless extra key.