Building a Billing Engine That Supports Every Pricing Model — Without Rewriting Your API

Most SaaS billing architectures tie pricing logic directly to service endpoints — each plan is a code path, each metric change touches the same handler. That works until you need two different monetization models for similar usage patterns (freemium with hard caps vs. tiered per-unit), and the conditional logic starts looking like feature-flag spaghetti.

The alternative is to separate what the customer interacts with from how it’s priced. This post walks through a Python example where the public API never knows about pricing models, and new monetization strategies can be registered without modifying existing plans or handlers.

The code

The first design decision is what your API exposes. If you model your interface around internal data — “tier” IDs, database column names, flag booleans — every business change requires touching the contract. Instead, expose what customers actually care about: usage against defined boundaries.

@dataclass(frozen=True)
class UsageRecord:
    metric: str          # e.g. "api_calls", "storage_gb"
    quantity: float
    timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))

@dataclass(frozen=True)
class PlanFeature:
    name: str
    limit: float | None   # None = unlimited
    unit: str             # what the limit measures

The engine uses Python’s typing.Protocol for structural subtyping — any class with the right methods satisfies the interface, no inheritance needed. The engine routes by plan name to whichever strategy is registered.

class PricingStrategy(Protocol):
    def compute_cost(self, usage: list[UsageRecord], plan_features: list[PlanFeature]) -> float: ...
    def describe(self) -> str: ...

The PricingEngine itself is deliberately boring — it’s a dictionary lookup:

class PricingEngine:
    def __init__(self) -> None:
        self._registry: dict[str, PricingStrategy] = {}

    def register(self, plan_name: str, strategy: PricingStrategy) -> None:
        self._registry[plan_name] = strategy

    def bill(self, request: ServiceRequest) -> dict:
        strategy = self._registry.get(request.plan_name)
        if not strategy:
            return {"error": f"Unknown plan: {request.plan_name}"}
        cost = strategy.compute_cost(request.usage, request.features)
        return {..., "amount": cost, ...}

No if/elif. No feature flags. Just register and bill.

Three concrete strategies come from different business needs:

  • FreeTier — always zero until a per-metric cap is hit, then hard stop (OverflowError)
  • PerUnitUsage — total volume determines rate tier; the more they use, the cheaper each unit gets
  • PerSeat — fixed base price with optional overage on specific metrics

Each strategy owns its own pricing math. None of them touch routes, data models, or other strategies.

Running it

The engine handles four customers across three pricing models:

  • cust-001 on FreeTier (starter plan) — under the per-metric caps for both api_calls and storage, so the bill is $0.00.
  • cust-002 on PerUnitUsage (pro plan) — 45,072 total units at the tiered rate of 0.005/unitlandsat0.005/unit lands at 225.36.
  • cust-003 on PerSeat (enterprise plan) — fixed $499.00 seat price with no overage since enterprise has unlimited api_calls and storage.

Then a fourth customer is billed under a newly registered FlatMonthly strategy — same API call, zero changes to the engine or plans:

  • cust-004 on Pro-Flat — 120k API calls + 95 GB storage for 99.00flat.Underperunitpricingthesameusagewouldcost99.00 flat. Under per-unit pricing the same usage would cost 240.19.

Takeaway

Design your external interface around customer outcomes (usage against limits), then let every pricing model speak that language through a Protocol. The cost is one extra indirection layer; the gain is that monetization strategy becomes a deployment-time decision, not a codebase-wide refactor. Adding a new pricing model means writing one class and calling register — no touching of routes, data models, or existing strategies.