Skip to main content

/writing

Encoding Pricing Rules Without Forking the UI

· 3 min read

A marketing site I worked on priced several products, and no two priced the same way. One charged by usage across a few dimensions, with multipliers. One was tiered by stored data and scaled by monthly active users. One mixed time-based units, storage tiers, and addons that applied to some plans and not others. New products kept arriving with new billing models.

The UI was fine. An earlier round of work had already solved the sliders, the comparison tables, the plan cards and the mobile grids. What kept breaking was the math behind them. Every new billing rule tempted someone to add another conditional inside a component, and two products in, that was already going badly.

Plans as data

I moved the rules out of the components and into the plan definition. Each plan declared what kind of calculation it used and what inputs it took. The calculations themselves lived in shared functions, one per billing model. The component got generic: read the plan, render the inputs it asks for, call the right function.

Adding a billing dimension stopped being a UI change. A new tier, a new multiplier, an addon that only applies above some threshold: each one is a small addition to the schema and maybe a new function. The components never notice.

export const storagePlan = {
  model: 'tiered',
  inputs: [
    { key: 'storedGb', label: 'Stored data', max: 5_000 },
    { key: 'mau', label: 'Monthly active users', max: 100_000 },
  ],
  tiers: [
    { upTo: 100, unitPrice: 0 },
    { upTo: 1_000, unitPrice: 0.12 },
    { upTo: Infinity, unitPrice: 0.08 },
  ],
  addons: [{ key: 'sso', price: 99, requiresTier: 1 }],
} satisfies PlanDefinition;

Four products later

Chat, video, activity feeds and moderation all render from the same component now. Each keeps its own folder of plan data: the cards, the comparison rows, the prices, the feature lists. None of them knows the others exist.

A product that prices unusually does not fork the renderer. It adds a small slice that overrides the part that differs and inherits the rest. Moderation needed its own cards. Feeds needed its own overage tables. Both are additions sitting next to the shared renderer rather than edits to it.

The real test of the boundary is what a new product line costs. It is a folder of data and, at most, a small slice. Nobody opens the component that draws the page.

The legacy corner

Pricing carries history. Customers on older plans still need to see what they signed up under, even after the public rules move on. The schema handles those as named legacy plans pointing at frozen rule sets. New products launch in the main schema and legacy plans live in their own corner of it.

It is not the cleanest separation. It follows the same rule though: the plan owns its pricing, never the component.

What I learned

Rules inside components grow by branching. Rules inside data grow by extension. The first time you catch yourself checking a plan slug inside a pricing component, the schema is wrong.

Once that line is drawn, a new product is a schema entry and nothing else.