← Insights
build

Pricing and Billing Architecture for B2B SaaS

Your pricing changed and the billing code is now a minefield nobody will touch. How to separate the pricing model from the billing engine before the next change breaks revenue

Sales closed a deal at a price that doesn't exist in your system. So an engineer wrote a conditional. Then enterprise wanted annual with a custom seat band, so there was another conditional. Then someone grandfathered the first forty customers onto the old plan, and now the billing code is a swamp of if customer.id in LEGACY_SET that exactly one person understands, and that person is on vacation.

Every pricing change ships as a code change. Every code change near money is a deploy nobody wants to own. The thing that's supposed to make you money has become the thing the team is most afraid to touch. That fear is not a discipline problem. It's an architecture problem, and it's fixable.

The mistake: pricing logic and billing mechanics are the same code

The root cause is almost always one entanglement. The rules for what a customer should be charged are braided into the machinery that actually charges them. Plan definitions live next to invoice generation. Discount math sits inside the Stripe call. Entitlements — what features a plan unlocks — are inferred from the price the customer pays.

When those three concerns share a file, every change to one risks the other two. Change a price and you risk breaking entitlements. Add a discount and you risk the invoice math. There's no seam to test in isolation, so every change is a full-system change, and the blast radius is your revenue.

The fix is three separate layers that know almost nothing about each other.

Layer one: the pricing model

The pricing model is data, not code. It is a declarative description of what plans exist, what they cost, what they include, and under what terms. It does not charge anyone. It does not call Stripe. It answers exactly one question: given this customer and this plan, what do they owe and what do they get?

A workable shape:

plan {
  id, name, version
  billing_interval        // monthly | annual
  base_price
  included_entitlements   // { seats: 5, api_calls: 10000, ... }
  metered_dimensions      // [] for flat plans
  add_ons                 // [{ id, price, entitlement_delta }]
}

The two non-negotiable design choices here:

Plans are versioned and immutable. You never edit a plan in place. When pricing changes, you publish pro_v3, and existing customers stay pinned to pro_v2 until you migrate them deliberately. This is the single decision that kills the LEGACY_SET conditional forever. Grandfathering stops being special-case code and becomes the default behavior of an immutable, versioned catalog. A customer's subscription points at a plan version; that pointer is the whole grandfathering story.

Entitlements are decoupled from price. What a customer can do is a separate question from what they pay. Store entitlements as their own resolved record on the subscription, not as a lookup against the price tag. The day sales gives an enterprise customer a custom seat count at a standard price, you set their entitlements directly and the system doesn't blink. Price and capability move independently because they were never the same field.

Layer two: the billing engine

The billing engine is the machinery. It takes subscriptions and plan versions and turns time into invoices. This is where proration, periods, credits, and the actual charge lifecycle live. It reads the pricing model; it never embeds pricing rules.

The hard part of a billing engine is not the happy path. It's the events that change a subscription mid-cycle, and proration is where most homegrown billing quietly bleeds money.

Proration is a ledger, not a formula. The naive approach computes a single prorated number when a plan changes and moves on. That works until a customer upgrades, downgrades, and upgrades again in the same cycle, and your single-shot math double-counts a credit. Model the billing period as a sequence of line items, each with a start, an end, a rate, and a reason. An upgrade closes the current line item and opens a new one. The invoice is the sum of the line items in the period. Now proration is auditable: you can point at every cent and say which subscription state produced it. When a customer disputes a charge, you have the answer in the data instead of in a reconstruction.

Periods are explicit records, not computed on the fly. Each billing period for each subscription is a row with a defined window and a status — open, finalized, paid, failed. You generate invoices by closing periods, not by running a cron job that recomputes everything every night. This makes the system idempotent: re-running the close on a finalized period is a no-op, not a double charge.

Layer three: the payment processor (Stripe is not your billing system)

Here is the point that costs teams the most when they get it wrong: Stripe is a payment processor, not your billing system.

Stripe moves money and stores cards. It is excellent at that. The temptation is to let Stripe's data model become your data model — to treat Stripe Subscriptions as your subscriptions, Stripe's invoices as your invoices, Stripe's webhooks as your source of truth. Teams reach for this because it's faster on day one, and it is. The cost arrives later.

When Stripe owns your billing state, three things break. You can't answer billing questions without an API call to a third party. You can't change processors without a migration that touches your entire revenue model. And you can't represent anything Stripe doesn't natively support — custom proration rules, internal credits, non-card settlement for an enterprise deal that pays by wire.

The architecture that holds: your system is the source of truth for what is owed; Stripe is the source of truth for what was paid. Your billing engine computes the invoice. You hand Stripe a finalized amount to collect. Stripe tells you, via webhook, whether the money arrived. Your subscription state transitions on that signal. Stripe never decides what a customer owes — it only executes the collection and reports the result.

This means a thin, well-defined boundary. One adapter translates your finalized invoices into Stripe charges and translates Stripe's payment events back into your domain. Everything Stripe-shaped lives behind that adapter. The rest of your system speaks your language, not Stripe's.

The failure modes, named

These are the four ways billing systems lose money or trust, and what stops each:

  • Webhook races and duplicates. Stripe delivers events at-least-once and out of order. A payment_succeeded can arrive before the invoice_finalized you'd expect to precede it. Every webhook handler must be idempotent and keyed on the event, and your subscription state machine must tolerate events arriving in the wrong order. Process the same event twice and you've either double-credited an account or double-charged a customer. Both are incidents.
  • Silent revenue leakage. A customer upgrades, the entitlement updates, the invoice line item doesn't. They get the bigger plan and pay the smaller price, and nobody notices until a finance reconciliation months later. The defense is reconciliation as a first-class job: a scheduled pass that compares what every active subscription should be billing against what was actually collected, and alerts on the gap.
  • Proration drift. The mid-cycle change math disagrees with itself across upgrade-downgrade sequences. The line-item ledger model above is what prevents this — never a single computed number.
  • Failed-payment limbo. A card declines and the subscription enters an undefined state — still entitled, not paying, no dunning, no clear path back. You need an explicit lifecycle: past-due, retrying, suspended, recovered, each with defined entitlement consequences and a defined exit.

What fixed looks like

Pricing changes stop being deploys. Sales closes a new plan structure and someone publishes enterprise_v4 to the catalog — data, reviewed, not code shipped at 6 p.m. on a Friday.

Grandfathering is the default, because plans are versioned and immutable and a subscription just points at the version it was sold on. The LEGACY_SET conditional is deleted and never returns.

Entitlements move independently of price, so a custom enterprise deal is a record, not a special case in the charging code.

Your system knows what every customer owes without calling Stripe, because your billing engine — not the processor — is the source of truth for revenue. Stripe collects and reports; it doesn't decide.

And reconciliation runs on a schedule, so revenue leakage surfaces in a dashboard instead of in a board meeting.

./bill --pricing=data --engine=ledger --stripe=adapter-only

This is for you if

You're a funded B2B SaaS doing real revenue, your pricing has changed at least once and is going to change again, and the billing code has become the part of the codebase the team negotiates over who has to touch. This is a $50k+ engagement: separating the pricing model from the billing engine, building the proration ledger, and putting Stripe behind a boundary so the next pricing change is a data change. For platforms heading into enterprise deals with custom terms, multiple processors, or usage components, it's the $100k+ version, where reconciliation and the full subscription lifecycle become load-bearing.

It's the right time if pricing changes ship as deploys, if you can't answer "what does this customer owe" without calling a third party, or if a finance reconciliation has already surfaced a number that didn't match.

This is not for you if you have one flat plan, one price, fewer than fifty customers, and no near-term plan to change any of it. Stripe Billing off the shelf is the correct answer at that stage, and building this architecture early is solving a problem you don't have yet. Come back when pricing gets complicated, because it will.

< transmit >