Skip to main content

Channel cost rollup vs the billing ledger model

Devotel Orbit exposes two surfaces for the same metered usage:
  1. Insights → Costs — the aggregate view. GET /api/v1/analytics/costs returns totals, a time series, and a per-channel breakdown over a date window, grouped by group_by (day, week, or month).
  2. Billing → Usage records / Cost-center chargeback — the ledger view. One row per priced message or completed call, and the metadata.cost_center rollup that attributes spend to a department.
Both read the same underlying rows, but they are not the same model. The rollup is a planning surface; the ledger is the invoicing surface. Know which one you are looking at before you treat a number as the answer.

1. What “resolves” means

A row resolves when the price stamped on it at send time flows into the wallet ledger as a single debit. The Insights → Costs page reads those priced rows and rolls them up per channel; the Billing → Cost-centers page reads the same priced rows and folds them by metadata.cost_center. Nothing moves between the two views — they are two projections of the same ledger. The distinction matters when you reconcile: a row that failed to rate has an empty price, so it stays out of the billed messages count (and does not dilute avg_cost_per_message) but it still appears on the usage-records feed. “Resolves” means: priced → ledger debit → one of the two projections above.

2. Why the insights page and the billing/cost-centers page disagree

They read different projections by design:
  • Insights → Costs is a channel aggregate. It groups by channel (sms, whatsapp, voice, …) and by currency, and it only counts rows where price > 0. Failed sends, free-tier sends, and unrated rows are excluded from the billed count. The totals tile and the billed-messages tile both come from that filter.
  • Billing → Usage records / Cost-centers is ledger-owned. It walks the raw rows (inbound and outbound, priced and unpriced) and attributes spend by metadata.cost_center — or reports it as unallocated when the tag is missing.
Because the insights page filters to price > 0 and the ledger page filters only to outbound usage, the two can legitimately disagree on a given day — especially if you have inbound traffic, failed sends, or unrated rows. That is not a bug; it is the filter working as documented.

3. The numerics — currency, rounding, and segment attribution

Currency. Every rollup row carries an ISO-4217 currency. Multi-currency tenants get one row per (channel, currency) pair in by_channel, and one point per (period, currency) in time_series. The dashboard merges those per-channel rows when it renders the donut — summing spend and messages and recomputing the average — so the API is the source of truth when you need exactly one currency. Rounding. avg_cost_per_message is total_spend / billed_messages, with both computed over rows where price > 0, rounded to four decimal places. Treat it as a display hint, not a ledger value. Segments. SMS bills per segment, not per message. The rollup reports total_segments alongside total_messages for exactly this reason: a multipart SMS counts as one “billed message” but several segments, and the segment count is what the price was computed from. When you reconcile against the usage-records feed, join on segments (units) for SMS, not on message count.

4. When to use one surface vs the other

Use the rollup for planning and trend reading; use the ledger for invoicing and audit. Never treat the rollup total_spend as an invoice line — the invoice is built from the ledger, not the aggregate.

5. Which response field maps to which ledger row

GET /api/v1/analytics/costs returns an aggregate object; the ledger is the row feed behind it. The mapping is per filter window, not row-level: When you reconcile against GET /api/v1/usage-records, apply the same date filter and group by channel + currency — the sum of the rollup’s by_channel rows will match the sum of the ledger’s filtered rows, with the segments caveat above.

Worked example: SMS to Russia

You send 12,000 outbound SMS to Russia in June. 1,800 of them are multipart (two segments each), so the raw segment count is 13,800, and 240 rows failed to rate and carry an empty price.
  • Insights → Costs over the same window: by_channel shows channel: "sms", total_messages: 11,760 (only rows with price > 0), total_segments: 13,560 (the billed segments), and total_spend — say **102.48at102.48** at 0.0075 per segment.
  • Billing → Usage records over the same window: the filtered ledger lists all 12,000 rows (inbound filtered out), both priced and unpriced, and the sum of price across the priced rows matches the $102.48 above.
  • Billing → Cost-centers (chargeback-rollup): if the sends were tagged metadata.cost_center: dept_russia, that department line absorbs the full $102.48; the 240 unrated rows land in unallocated until they rate.
If the widget totals on the Costs page look lower than the raw row count on the usage-records feed, that gap is the price > 0 filter removing unrated rows — not missing data.