Rates & Tariffs
Code lives in
app/models/rate.py, app/schemas/rate.py, app/services/rates.py,
app/api/v1/routes/rates.py, plus the tariff_categories catalog
(app/models/tariff_category.py, app/services/tariff_categories.py,
app/api/v1/routes/tariff_categories.py).
Business term: Product. A Rate is what the business calls a Product; Rate is the internal
name and stays. Tax rates and tax mapping have been removed (see
Current feature scope).
A rate card defines price per usage type/unit; tariff resolution picks the applicable price tiers for a given usage type and (optionally) plan. See "Not implemented" for what is out of scope.
Terminology follows the BillRun documentation: Product Catalog.
Data model
Rates are one row per key, with no revision history - the same simplification Plans use:
rates
id uuid, PK
tenant_id uuid, FK
key str - business key, "^[A-Z0-9_]+$"; unique per tenant among
live rows (partial index)
title str
description str, nullable
pricing_method str - "tiered" | "volume"
tariff_category_id uuid, FK - tariff_categories.id (tenant-scoped picklist)
add_to_retail bool - auto-true when the referenced category's
value is TariffCategoryCode.RETAIL ("retail")
billable bool - default true
invoice_label str, nullable
access float, nullable - flat access/connection fee
rates jsonb - {"<usage_type>": {"base": {tariff} | null,
"by_plan": {"<plan_name>": {tariff}}}}
rounding_rules jsonb, nullable
tax jsonb - pass-through list, not validated
source / metadata str / jsonb - "manual" or connector source; raw
synced record (`rate_sync`)
created_at / updated_at / deleted_at
There is one row per key, with no version history - same as
Plans: every create/update/delete is
recorded in the shared History log (domain="rate"), an audit trail that
doesn't give Rating a way to look up "the rate as of date X" directly off rates.
Tariff map shape
Rate.rates[usage_type] is {"base": {tariff} | null, "by_plan": {"<plan_name>": {tariff}}}
(UsageTypeTariffs, app/schemas/rate.py). base/by_plan are structural field names, not
values sharing a namespace with plan names, so no plan name can ever collide with a sentinel and no
reserved-word blocklist is needed anywhere in this codebase. UsageTypeTariffs uses
extra="forbid", so a stray "BASE" or "groups" key fails loudly at request-validation time
instead of being silently misread.
Pricing engine
- Tier pricing math -
get_charge_for_tier_step(theceil-vs-linear per-tier formula),get_charge_by_tiers("tiered": each tier's slice charged separately;"volume": the whole volume charged at its single containing tier's rate),get_charge_by_volume(negative-volume/refund sign flip, plus the flataccessprice). Pure functions inapp/services/rates.py, the highest-value part of this domain to get exactly right. - Tariff resolution (
resolve_tariff,get_charge) - priority: plan override map → rateby_plantariff →base, pluspercentage-override reinterpretation as a multiplier againstbase. Passplan_rates_overridefrom the Plan's.ratesJSON. - Inverse pricing (
get_volume_by_price) - binary-search root-finder: the volume whose charge equals a target price, for a monotonically non-decreasing charge function. Self-contained; it is not tied to Balance grants (see below). - CRUD + validators:
keyformat,pricing_methodrequired,tariff_category_idis a real FK totariff_categories(admin-managed picklist;GET /tariff-categoriesis the dropdown).add_to_retailis auto-set when the referenced row'svalueisTariffCategoryCode.RETAIL(_add_to_retail_for). Engine-known codes live in that enum, not as a string column onrates.by_planparent existence is checked against the plan catalog (_validate_rate_parents, reusing Plans'get_plan_by_name) and tier continuity byTieredRateEntry. Usage types are free-form strings - there is no usage-type catalog check.
Not implemented
- Revisioned rates (date-ranged validity windows, close-and-new / move / reopen operations) - rates are one row per key; nothing needs date-ranged rate history yet.
- Group-scoped tariffs and live include-group selection - Plan
includestorage exists; live group selection / balance group counters do not. Group-scoped tariffs would need their own explicit field inUsageTypeTariffs, not a third magic string sharingby_plan's namespace. - Granted-volume for prepaid - Balances exists, but nothing yet ties
get_volume_by_priceto a real Balance grant flow. - Full rating pipeline - an async, queue-based batch/realtime engine that applies a rate to
usage lines is a separate future item. Lines has a minimal synchronous
stand-in (
rate_line, calling this domain's ownget_chargewith an explicitrate_key). Rate selection is not built (rate_linestill requires the key). - Import/Export CSV - no domain has CSV import/export yet.
- Tax validation against real tax records - tax rates were removed;
taxis pass-through JSON.
API
GET /api/v1/rates follows the pagination convention with no
extra filters (RatesPaginatedResponse). Two read-only computation endpoints expose the
pricing engine for ops/debugging. Tariff categories are managed at /api/v1/tariff-categories
(POST, GET list, GET/PATCH/DELETE /{id}).
| Method | Path | Notes |
|---|---|---|
| POST | /api/v1/rates | create |
| GET | /api/v1/rates | paginated list |
| GET | /api/v1/rates/{rate_id} | fetch one |
| PATCH | /api/v1/rates/{rate_id} | partial update |
| DELETE | /api/v1/rates/{rate_id} | soft delete - stamps deleted_at; the key becomes reusable immediately |
| GET | /api/v1/rates/{rate_id}/charge | ?usage_type=&volume=&plan=&offset= |
| GET | /api/v1/rates/{rate_id}/volume-for-price | ?usage_type=&price=&plan= |