Skip to main content

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 (the ceil-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 flat access price). Pure functions in app/services/rates.py, the highest-value part of this domain to get exactly right.
  • Tariff resolution (resolve_tariff, get_charge) - priority: plan override map → rate by_plan tariff → base, plus percentage-override reinterpretation as a multiplier against base. Pass plan_rates_override from the Plan's .rates JSON.
  • 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: key format, pricing_method required, tariff_category_id is a real FK to tariff_categories (admin-managed picklist; GET /tariff-categories is the dropdown). add_to_retail is auto-set when the referenced row's value is TariffCategoryCode.RETAIL (_add_to_retail_for). Engine-known codes live in that enum, not as a string column on rates. by_plan parent existence is checked against the plan catalog (_validate_rate_parents, reusing Plans' get_plan_by_name) and tier continuity by TieredRateEntry. 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 include storage exists; live group selection / balance group counters do not. Group-scoped tariffs would need their own explicit field in UsageTypeTariffs, not a third magic string sharing by_plan's namespace.
  • Granted-volume for prepaid - Balances exists, but nothing yet ties get_volume_by_price to 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 own get_charge with an explicit rate_key). Rate selection is not built (rate_line still requires the key).
  • Import/Export CSV - no domain has CSV import/export yet.
  • Tax validation against real tax records - tax rates were removed; tax is 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}).

MethodPathNotes
POST/api/v1/ratescreate
GET/api/v1/ratespaginated 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=