Plaid v2: reproduce v1's BPS-driven price for 7 Transfer SKUs

Ports v1's min(marginFlat, bpsFlat) price path into a v2 variable so 7 dynamic Plaid Transfer SKUs price to the cent against the v1 source of truth.

PRdealops#5739 Author@mehulshinde Beaddealops-xq9.36.6 Basemehul/cost-plus-margin Files10 +/−+5614 / −77 StatusOpen · Stacked

What it adds
A computeBpsTransferPrice helper and a transferBpsPrice registry variable that reproduces v1's BPS fallback path — tier lookup, level rate, clamp.
What it changes
7 dynamic Transfer SKUs in pricingSpec.json flip their listPriceVariableId from listPriceTop to transferBpsPrice and inline their 37-tier rate table.
What it preserves
No engine-core changes. The price is structured as min(marginFlat, bpsFlat) so the scope-A cost_plus_margin primitive re-engages if any SKU ever populates recommendedPricing.
v2 (Plaid)
v1 (penguin) — source of truth
BPS rate table
Resulting price
cost_plus_margin primitive (dormant)

1. Why this exists

Plaid's v1 system (penguin) is still the source of truth for live pricing. Seven Transfer SKUs price dynamically off a BPS rate table, not off a flat list price. In prod, every tier's recommendedPricing is null, so the live formula isn't cost/(1-margin) — it's the BPS fallback.

The v2 migration (dealops-xq9.36) needs v2 to reproduce v1 verbatim for these SKUs before opportunities can be cut over. This PR ports the path that actually runs, not the dormant margin branch.

Before — v2
All 7 SKUs pointed at listPriceTop, a generic list-price variable that does not know about BPS tiers, transaction size, or volume-driven tier selection. Prices would not match v1.
After — v2
Each of the 7 SKUs dispatches through transferBpsPrice, which reads its inline rate table + the customer's effective monthly minimum + avg transaction size and produces the v1 price to the cent.

2. The 7 SKUs

SKUCur.CostFloorCapTiers
Instant Payouts — FixedCAD0.1620.345.0037
Same-day ACH — High RiskCAD0.1220.703.0037
Same-day ACH — High Risk — FixedUSD0.1220.703.0037
Same-day ACH — Vanilla — FixedCAD0.08970.2253.0037
Standard ACH — High RiskCAD0.07360.453.0037
Standard ACH — High Risk — FixedUSD0.07360.453.0037
Standard ACH — Vanilla — FixedCAD0.04130.1253.0037

Each tier table has 37 breakpoints from tierMin: 0 to tierMin: 500000 with 4 approval levels per tier (level1level4).

3. The formula

price = clamp( min(marginFlat, bpsFlat), priceFloor, priceCap ) bpsFlat = R[level] × avgTransactionSize × 0.01 marginFlat = recommendedPricing == null ? bpsFlat : cost / (1 − recommendedPricing / 100)
Tier selection
T = highest tier with tierMin ≤ monthlyVolume. If volume is below every breakpoint, fall back to the lowest tier.
v1 0→500 quirk
If T.tierMin === 0 and a tier with tierMin === 500 exists, read the BPS rate from the tier-500 row, not tier-0. This is verbatim v1 behavior.
Why min(margin, bps)
In prod recommendedPricing is always null, so marginFlat = bpsFlat and min() is a no-op. The structure stays so scope-A's cost_plus_margin re-engages if a SKU ever sets a target margin.

4. What changes

Files touched
apps/server/src/dealops2/onboarding/plaid/pricingSpec.json+1953 / −77Modified
apps/server/src/dealops2/variables/plaid/bpsTransferPrice.ts+183Added
apps/server/src/dealops2/variables/plaid/registry.ts+92Modified
apps/server/src/dealops2/variables/plaid/__tests__/bpsTransferPrice.spec.ts+285Added
apps/server/src/dealops2/variables/plaid/__tests__/bpsTransferPrice.wiring.spec.ts+209Added
apps/server/src/dealops2/variables/plaid/__tests__/bpsTransferPrice.realspec.spec.ts+295Added
apps/server/src/dealops2/variables/plaid/__tests__/fixtures/bpsTransferPricing.fixture.json+2421Added
packages/types/v2/productSpec.ts+44Modified
rfcs/.../xq9.36.6-bps-driven-transfer-pricing.md+131Added
rfcs/.../README.md+1Modified

4a. The pricingSpec.json flip

Each of the 7 SKUs swaps its listPriceVariableId and adds an inline bpsTransferPricing block under calculationSpec:

Before
"listPriceVariableId": {
  "name": "listPriceTop"
}
After
"listPriceVariableId": {
  "name": "transferBpsPrice"
},
"bpsTransferPricing": {
  "cost": 0.162,
  "priceFloor": 0.34,
  "priceCap": 5,
  "currency": "CAD",
  "correspondingBpsProductId": "aacb55e7-…",
  "tiers": [ /* 37 rows */ ]
}

4b. Schema widening

packages/types/v2/productSpec.ts grows a bpsTransferPricing field on calculationSpecSchema (+44 lines). This is what lets productSpecSchema.safeParse validate the new SKU shapes — the realspec test asserts this directly.

4c. The helper

Pure function in bpsTransferPrice.ts:

export interface BpsTransferPriceInput {
  tiers: BpsTransferTier[];
  monthlyVolume: number;
  transactionSize: number;
  level: BpsApprovalLevel;     // 'level1' | 'level2' | 'level3' | 'level4'
  cost: number;
  priceFloor?: number;
  priceCap?: number;
}
export function computeBpsTransferPrice(input: BpsTransferPriceInput): number;

The registry variable in registry.ts wires it to v2 by reading three things off FormulaEvaluateAttr:

It returns a NumberCurrency at level1 (list price). No engine-core touched.

5. How it lines up with v1

Verified zero mismatch against live NEON. The PR description states v2 == verbatim v1 formula for all 7 SKUs. Curve selection matches v1 — the unregistered lookup_data curve is skipped, so the Default curve is used.

Three test files lock the behavior:

bpsTransferPrice.spec.ts
Pure-helper oracle. Drives computeBpsTransferPrice through the 2421-line fixture across all 7 SKUs × multiple cases × 4 levels. Includes explicit tests for the 0→500 quirk and the min(margin, bps) dispatch.
bpsTransferPrice.wiring.spec.ts
Synthetic-spec wiring. Builds a minimal productSpec with an inline bpsTransferPricing block, runs the registered transferBpsPrice variable through its evaluate(attr), asserts the returned NumberCurrency.
bpsTransferPrice.realspec.spec.ts
Real-artifact regression. Loads the actual pricingSpec.json, asserts all 7 SKUs are wired correctly, schema-validates each productSpec, and drives one CAD + one USD SKU end-to-end. Catches future corruption that the synthetic wiring spec would miss.
Fixture shape (excerpt)
{
  "name": "Instant Payouts - Fixed",
  "dynamicProductId": "fcb372d4-…",
  "correspondingBpsProductId": "aacb55e7-…",
  "currency": "CAD", "cost": 0.162,
  "priceFloor": 0.34, "priceCap": 5,
  "tierCount": 37,
  "tiers": [ /* 37 rows */ ],
  "cases": [
    { "label": "low-volume tier500, txn50",
      "monthlyVolume": 600, "transactionSize": 50,
      "selectedTierMin": 500,
      "expected": { "level1": 0.625, "level2": 0.5, "level3": 0.425, "level4": 0.34 } },
    { "label": "huge txn -> hits cap",
      "monthlyVolume": 600, "transactionSize": 100000,
      "expected": { "level1": 5, "level2": 5, "level3": 5, "level4": 5 } }
  ]
}

6. Worked example

Instant Payouts — Fixed (CAD), customer at monthly minimum 600, avg transaction size $50, level1:

  1. Tier select: T.tierMin = 500 (highest tier ≤ 600).
  2. 0→500 quirk: T.tierMin ≠ 0, so R = T.
  3. R.level1 = 1.25 (percent).
  4. bpsFlat = 1.25 × 50 × 0.01 = 0.625.
  5. recommendedPricing null → marginFlat = bpsFlat = 0.625.
  6. clamp(0.625, 0.34, 5) = 0.625 CAD

7. What it doesn't change

8. Risks & rollback

Stacked PR. This sits on mehul/cost-plus-margin (the scope-A primitive from dealops-xq9.36.5). Merge the base first. The scaffolding from xq9.36.5 is "largely subsumed here" per the description — worth a quick check that the base PR's primitive is still referenced cleanly.
Rollback is local. Revert is contained to pricingSpec.json (flip 7 SKUs back to listPriceTop) plus the new variable files. No DB migration, no engine-core change to unwind.
Watch: the rate tables were hand-extracted from a prod-copy NEON DB. If any of the 37×7 = 259 tier rows is off by a digit, prices drift silently. The realspec.spec.ts regression test guards against corruption of the JSON but not against an initial data-entry error — the "0 mismatch" verification against live NEON is the real backstop here.

9. Out of scope (per PR)