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.
computeBpsTransferPrice helper and a transferBpsPrice registry variable that reproduces v1's BPS fallback path — tier lookup, level rate, clamp.
pricingSpec.json flip their listPriceVariableId from listPriceTop to transferBpsPrice and inline their 37-tier rate table.
min(marginFlat, bpsFlat) so the scope-A cost_plus_margin primitive re-engages if any SKU ever populates recommendedPricing.
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.
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.
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
| SKU | Cur. | Cost | Floor | Cap | Tiers |
|---|---|---|---|---|---|
| Instant Payouts — Fixed | CAD | 0.162 | 0.34 | 5.00 | 37 |
| Same-day ACH — High Risk | CAD | 0.122 | 0.70 | 3.00 | 37 |
| Same-day ACH — High Risk — Fixed | USD | 0.122 | 0.70 | 3.00 | 37 |
| Same-day ACH — Vanilla — Fixed | CAD | 0.0897 | 0.225 | 3.00 | 37 |
| Standard ACH — High Risk | CAD | 0.0736 | 0.45 | 3.00 | 37 |
| Standard ACH — High Risk — Fixed | USD | 0.0736 | 0.45 | 3.00 | 37 |
| Standard ACH — Vanilla — Fixed | CAD | 0.0413 | 0.125 | 3.00 | 37 |
Each tier table has 37 breakpoints from tierMin: 0 to tierMin: 500000 with 4 approval levels per tier (level1…level4).
3. The formula
T = highest tier with tierMin ≤ monthlyVolume. If volume is below every breakpoint, fall back to the lowest tier.
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.
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
4a. The pricingSpec.json flip
Each of the 7 SKUs swaps its listPriceVariableId and adds an inline bpsTransferPricing block under calculationSpec:
"listPriceVariableId": {
"name": "listPriceTop"
}
"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:
- the inline
bpsTransferPricingblock offattr.productSpec.calculationSpec - the customer's effective monthly minimum via the existing
getEffectiveMonthlyMinimumValuepath - the
avgTransactionSizeterm (existing Plaid currency term)
It returns a NumberCurrency at level1 (list price). No engine-core touched.
5. How it lines up with v1
lookup_data curve is skipped, so the Default curve is used.
Three test files lock the behavior:
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.
bpsTransferPricing block, runs the registered transferBpsPrice variable through its evaluate(attr), asserts the returned NumberCurrency.
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:
- Tier select:
T.tierMin = 500(highest tier ≤ 600). - 0→500 quirk:
T.tierMin ≠ 0, soR = T. R.level1 = 1.25(percent).bpsFlat = 1.25 × 50 × 0.01 = 0.625.recommendedPricingnull →marginFlat = bpsFlat = 0.625.clamp(0.625, 0.34, 5) = 0.625 CAD✓
7. What it doesn't change
- No changes to the v2 engine core or evaluator.
- No changes to v1 / penguin code paths.
- No changes to other Plaid SKUs — only the 7 dynamic Transfer products listed above.
- No opportunity migration or historical-quote replay (deferred to
dealops-xq9.36.7). - No new dependency on
cost_plus_marginat runtime — the primitive stays dormant because all tiers'recommendedPricingis null in prod. - No change to currencies, billing units, or quote frequency on the affected SKUs.
8. Risks & rollback
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.
pricingSpec.json (flip 7 SKUs back to listPriceTop) plus the new variable files. No DB migration, no engine-core change to unwind.
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)
- Opportunity migration + historical-quote replay →
dealops-xq9.36.7 cost_plus_margindispatch scaffolding →dealops-xq9.36.5(the base of this stack)