Written for: developers building usage-based billing who want to see how one backend handles it.
Hey, fellow developer. If you've ever built "pay for what you use" billing, you've probably hit the same wall I did: you charge per gigabyte, every call is a few kilobytes, and suddenly your rounding is eating money. This post walks through how the backend in this boilerplate prices metered API data so every cent is accounted for, with a short look at the code.
I'll keep the code excerpts small. A stripped-down, runnable version of the pricing module with unit tests is in the metered-pricing-sample repo, so you can try the math yourself. The excerpts below are from the production module.
The billable metric
Before any math, you need to decide what you're counting. Here the metric is request body bytes plus response body bytes, and only for live-key calls that were answered with a status below 500. Test-key calls are free. Errors from our side don't cost the customer anything.
The included allowance comes first. Each plan has a number of bytes that's covered by the subscription. Only the bytes beyond that allowance are priced by the overage tiers.
Why rounding is the hard part
Say overage costs 600 cents per GiB. A single API call might be 4 KB. Its cost is:
4 KB × 600 cents / 1 GiB ≈ 0.0023 cents
If you round each call on its own, you'll round every call to zero, and you'll never bill anything. If you round up each call, you'll overcharge by a huge margin. Neither approach is correct.
The trick is to stop pricing each call in isolation. Price the cycle's total, then charge each call the difference. Look at this excerpt from pricing.py:
def cost_cents(billable_bytes: int, terms: PricingTerms) -> int:
"""``f``: the cycle's overage cost in cents at ``billable_bytes``, rounded half up once. A
discount scales the exact numerator, so it is applied before the single rounding."""
kept = 100 - terms.discount_percent
return (overage_numerator(billable_bytes, terms) * kept + 50 * GB) // (100 * GB)
def debit_cents(settled_bytes: int, call_bytes: int, terms: PricingTerms) -> int:
"""Cents to debit for a call of ``call_bytes`` settled at cumulative ``settled_bytes`` (>= 0)."""
return cost_cents(settled_bytes + call_bytes, terms) - cost_cents(settled_bytes, terms)
Each call is debited f(S + call) - f(S), where S is the cycle's running total. When you add up all the debits in a cycle, they telescope to f(S_end), so the total is exact, and the fractions of a cent carry forward instead of disappearing. The cycle's byte count is what moves; the cents follow from it.
A worked example
Take a Standard-style setup: 50 GiB included, overage at 600 cents per GiB, no discount. Suppose a cycle has used exactly 51 GiB. The overage is 1 GiB:
- The numerator is
1 GiB × 600 = 600·GiB. - Multiply by 100 (the
keptfactor with no discount):60000·GiB. - Add
50·GiBfor the half-up rounding, then divide by100·GiB:60050 / 100 = 600.
So the cycle costs 600 cents, which is $6.00. That number is exact, because there's no fractional cent left over.
Tiers
Tiers let you price the first chunk of overage at one rate and later chunks at a lower one. The loop in overage_numerator walks through them:
for tier in terms.tiers:
if overage <= lower:
break
upper = overage if tier.up_to_bytes is None else min(overage, tier.up_to_bytes)
total += (upper - lower) * tier.cents_per_gb
if tier.up_to_bytes is None:
break
lower = tier.up_to_bytes
Each tier only charges for the bytes that fall inside it. The last tier has no upper bound. The module also has a validate_tiers function that rejects tier lists which aren't ordered correctly, so a bad tier list can be caught before it's used to bill anyone.
Keep the numbers in one place
The useful habit here is that the cent-level math lives in one pure module. It has no database calls, no settings lookups, and no I/O, so you can test it with plain integers. Use integers for money too, never floats.
If you want the whole flow (holds, prepaid balances, free trials, and arrears), the boilerplate includes it.
Get the full boilerplate: Byteforge
Top comments (0)