Plaid v2: per-product avg transaction size → bps Transfer revenue

Ports v1's per-product avg transaction size input into v2 and wires it into the bps/percent Transfer revenue formula, so percent-priced transfers price off real dollar volume instead of transaction count alone.

PR dealops#5738 Author @mehulshinde Bead dealops-xq9.66 Follow-up dealops-9v3 Files 8 +/- +786 / -9 Org Plaid

Problem

v2 had avgTransactionSize as a dead quote-level variable — only read by the Signal 3x-crypto predicate. bps Transfer revenue was count × price, missing the dollar-volume term.

Fix

Two new calculationSpec markers: one renders the on-card input, one scales revenue. avgTransactionSize becomes a product-scoped term (Xendit pattern).

Result

15 Transfer SKUs collect the input. 8 genuine bps SKUs apply v1's count × price×0.01 × txnSize revenue. 7 dynamic "- Fixed" SKUs stay statically priced (xq9.36 owns that math).

requiresTransactionSize (FE input)
multiplyRevenueByTransactionSize (engine multiply)
avgTransactionSize (product-scoped term)

1. Why this exists

v1's Product Selection collects a per-product avg transaction size on every bps / percent / dynamic Transfer card. That value is the difference between pricing a transfer at 5 bps × 1000 txns × $200/txn (= $100k of revenue) and pricing it at 5 bps × 1000 txns (= $50 of revenue — meaningless).

v2 inherited the variable name but not the wiring. avgTransactionSize existed as a quote-level static currency with value: 0, used only by the Signal 3x-crypto list-price predicate. No UI input, no revenue multiply. Percent-priced Transfer revenue therefore landed orders of magnitude too low.

Context

This is bead dealops-xq9.66 in the Plaid v2 onboarding effort. Per-row gating of the input column (today gated at category-level on Transfer) is split out into follow-up dealops-9v3.

2. The two-marker design

The PR introduces two booleans on calculationSpec. They live on different SKUs because the FE input and the revenue multiply have different scopes.

Marker 1 · FE-only

requiresTransactionSize

Renders the per-product Avg. Txn Size input column on the product card. Display-only — does not change revenue.

Applied to: all 15 bps + dynamic Transfer SKUs.

Marker 2 · Engine

multiplyRevenueByTransactionSize

Engine multiplies the flat revenue component by avgTxn × 0.01, mirroring v1's bps/percent formula.

Applied to: only the 8 genuine bps SKUs (list price is a bps number).

Why split the markers? The 7 dynamic "- Fixed" Transfer SKUs are shipped today as static consumption with flat $/txn pricing — bead xq9.36 will later build their cost-plus-margin math. If they carried the revenue marker, their flat $/txn revenue would inflate by ~5x once a rep entered an avg txn size. They need the input field (so reps capture it), but they must not apply the multiply yet.

3. Before / after revenue math

v1 (penguin_calculator.ts:1388):
revenue = count × (price × 0.01) × transactionSize
Before — v2 today
revenue = count × price

1000 txns × 50 bps = $50,000 (wrong units — bps multiplied as dollars)

After — gated on marker
revenue = count × price × 0.01 × avgTxnSize

1000 × 50 × 0.01 × $200 = $100,000 (matches v1)

SKU coverage matrix

Product code Type requires… multiplyRevenue…
PAY - BPSGenuine bps
PYT - UFGenuine bps
TRFIP - BPSGenuine bps
TRFSDHR - BPSGenuine bps
TRFSDVO - BPSGenuine bps
TRFSHR - BPSGenuine bps
TRFSVO - BPSGenuine bps
VRPS - UFGenuine bps
— dynamic "Fixed" SKUs below: input only, no revenue multiply —
(7 dynamic Transfer SKUs)Dynamic Fixed

4. The product-scoped avgTransactionSize term

Reshaping avgTransactionSize from a quote-level static currency into a per-product formula variable follows the existing Xendit pattern (variables/xendit/registry.ts). The variable now resolves through a four-step fallback at evaluation time:

1Per-product override on attr.product.terms — the "Custom per product" row on a product card.
2Scoped toolbar term avgTransactionSize_<categoryId>_<country> — e.g. avgTransactionSize_transfer_ (Plaid specs have no country tag, hence the trailing underscore).
3Global toolbar term avgTransactionSize.
4Default — currency-typed 0.
New helpers in registry.ts

5. The engine multiply

The multiply is a single helper added to the generic engine flat path in pricingEngineService.ts, applied at three call sites (two recurring branches + the absolute month_idx branch). It's gated solely on the revenue marker:

const applyAvgTxnSizeMultiply = (flatRevenue: number): number => {
  if (!productSpec.calculationSpec.multiplyRevenueByTransactionSize)
    return flatRevenue;
  const txnSizeTerm = getTerm(productAttr, 'avgTransactionSize');
  const avgTxnSize =
    txnSizeTerm?.type === 'currency' ? txnSizeTerm.value : 0;
  if (!avgTxnSize) return flatRevenue; // v1 fallback: no txn size → plain revenue
  return flatRevenue * avgTxnSize * 0.01;
};

Three things to notice:

6. UI wiring in pricingFlowSpec.json

A new column is added to the Transfer product-table, scoped via visibleForCategories: ["transfer"]:

{
  "term": {
    "id": "avgTransactionSizeRow",
    "variableId": "avgTransactionSize",
    "isUserEditable": true,
    "uiConfig": {
      "type": "currency",
      "title": "Avg. Txn Size",
      "align": "right",
      "simpleInput": true,
      "validation": { "min": 0, "precision": 0 }
    }
  },
  "visibleForCategories": ["transfer"],
  "width": "150px",
  "maxInputWidth": 117
}

The column shows on the whole Transfer category — finer per-row gating (showing it only on bps + dynamic rows, hiding on flat-fee rows in the same category) is the explicit scope of follow-up dealops-9v3.

7. What it doesn't change

8. Test coverage

Engine — 320 LOC

avgTransactionSizeMultiply.spec.ts

Three SKU shapes (marked / dynamic-Fixed / unmarked control) across monthly, annual, and absolute month_idx branches. Guard cases for missing/0 txn size, plus product-scoped term precedence end-to-end through the registry.

Registry — 231 LOC

avgTransactionSize.test.ts

Asserts isProductVariable: true, formula-type value, the four-step precedence chain, and the Signal 3x-crypto regression path.

Schema — 48 LOC

requiresTransactionSizeMarker.test.ts

Confirms both markers survive zod parse, are independently optional, and don't get stripped.

The engine spec is written Jest-style; the registry and schema specs are Mocha — consistent with the package's mixed test conventions (new tests default to Mocha; the engine file uses Jest because its sibling specs already do).

9. Risks & open questions

Forgetting to set the revenue marker on a future bps SKU would silently drop its revenue by ~100x (a 5 bps × $200 SKU would price as $50 instead of $100k). The marker is per-SKU JSON, with no schema-level enforcement that "bps" SKUs must carry it. Worth a follow-up lint or a derived check from product code suffix.
Rollback is clean. Both markers are optional booleans; absent ⇒ pre-PR behavior. Reverting pricingSpec.json alone (without touching the engine code) restores old revenue numbers without breaking anything.
FE column appears on all Transfer rows today — including flat-fee rows that have no use for avg txn size. Confirmed scope-tradeoff: shipped this way and split out as dealops-9v3 rather than block this bead on per-row gating.