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.
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.
Two new calculationSpec markers: one renders the on-card input, one scales revenue. avgTransactionSize becomes a product-scoped term (Xendit pattern).
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.
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.
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.
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).
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
revenue = count × (price × 0.01) × transactionSize
1000 txns × 50 bps = $50,000 (wrong units — bps multiplied as dollars)
1000 × 50 × 0.01 × $200 = $100,000 (matches v1)
SKU coverage matrix
| Product code | Type | requires… | multiplyRevenue… |
|---|---|---|---|
PAY - BPS | Genuine bps | ✓ | ✓ |
PYT - UF | Genuine bps | ✓ | ✓ |
TRFIP - BPS | Genuine bps | ✓ | ✓ |
TRFSDHR - BPS | Genuine bps | ✓ | ✓ |
TRFSDVO - BPS | Genuine bps | ✓ | ✓ |
TRFSHR - BPS | Genuine bps | ✓ | ✓ |
TRFSVO - BPS | Genuine bps | ✓ | ✓ |
VRPS - UF | Genuine 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:
attr.product.terms — the "Custom per product" row on a product card.avgTransactionSize_<categoryId>_<country> — e.g. avgTransactionSize_transfer_ (Plaid specs have no country tag, hence the trailing underscore).avgTransactionSize.0.getPlaidScopedTermValue(attr, baseId)— builds the${baseId}_${category}_${country}key, falls back to the global term, then to 0.getPlaidProductTermUserValue(attr, termId)— readsattr.product.termsdirectly. Deliberately does not callgetVar/getTermto avoid circular evaluation with the formula variable itself.
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:
- Revenue-only. The displayed per-unit bps price is unchanged. Only the computed revenue number moves.
- v1's
if (transactionSize)guard is preserved. When the marker is set but the term is 0 or missing, revenue falls back tocount × pricerather than collapsing to zero. productAttris product-scoped. The helper threadsproductandproductSpecinto the attr sogetTermcan hit the per-product override first.
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
- The displayed bps price. Per-unit price on the card is untouched; only revenue moves.
- Dynamic "- Fixed" SKU revenue. They collect the input but their flat $/txn revenue is preserved until
xq9.36ships their cost-plus-margin model. - The Signal 3x-crypto consumer.
resolveSignalListPricestill callsgetTerm(attr, 'avgTransactionSize')— that path falls throughgetVarinto the new formula and continues to yield a currency value. Tests explicitly assert this with a scoped-only term so the reshape path is exercised. - Non-Transfer categories. The FE column is gated to
"transfer"; other categories see no UI change. - Quotes with no avg txn size. Marker present + value missing/0 ⇒ revenue stays
count × price, matching v1.
8. Test coverage
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.
avgTransactionSize.test.ts
Asserts isProductVariable: true, formula-type value, the four-step precedence chain, and the Signal 3x-crypto regression path.
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
pricingSpec.json alone (without touching the engine code) restores old revenue numbers without breaking anything.
dealops-9v3 rather than block this bead on per-row gating.