> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Channel cost rollup vs the billing ledger model

> Why Insights → Costs by-channel totals and Billing → Usage records / cost-center chargeback can legitimately disagree — the analytics rollup is an aggregate over priced sends, the ledger is the row-level debit stream, and reconciliation is a per-period, per-channel, per-currency join, not a row match.

# 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

| Question                                                                          | Read                                                             |
| --------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| "What did we spend on WhatsApp last month?" — planning, comparison                | Insights → Costs (`GET /api/v1/analytics/costs?group_by=month`)  |
| "Which exact rows stand behind the invoice line?" — invoice audit                 | Billing → Usage records (`GET /api/v1/usage-records`)            |
| "Which department carries this spend?" — chargeback                               | Billing → Cost-centers (`GET /api/v1/billing/chargeback-rollup`) |
| "Is inbound traffic inflating my costs?" — the insights page ignores it by design | Billing → Usage records (it lists both directions)               |

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:

| `GET /api/v1/analytics/costs` field | Ledger row source                                                                                      |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `totals.total_messages`             | Count of rows in `GET /api/v1/usage-records` with `price > 0` over the same window                     |
| `totals.total_spend`                | Sum of `price` over those same rows                                                                    |
| `totals.avg_cost_per_message`       | `total_spend / total_messages` recomputed client-side; use the raw rows if you need the exact quotient |
| `totals.total_segments`             | Sum of `units` over SMS rows (multipart SMS contributes >1 unit)                                       |
| `totals.by_currency[]`              | The same totals, split per `(currency)`                                                                |
| `time_series[].period`              | Rows bucketed by the `group_by` granularity (day/week/month), filtered to the same window and currency |
| `by_channel[].channel`              | Rows grouped by `channel` over the same window, per `(channel, currency)`                              |
| `by_channel[].total_spend`          | Sum of `price` for that `(channel, currency)` bucket                                                   |
| `by_channel[].total_messages`       | Count of priced rows for that `(channel, currency)` bucket                                             |
| `by_channel[].total_segments`       | Sum of `units` for that `(channel, currency)` bucket                                                   |

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.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.

***

## Related

* [Usage records: the per-record billing feed](/concepts/usage-records-model)
  — the ledger rows behind every aggregate on this page.
* [Wallets, credits, and charges](/concepts/wallets-credits-and-charges) —
  how each resolved row turns into a wallet debit.
* [Cost-center chargeback](/guides/cost-center-chargeback) — how
  `metadata.cost_center` tags feed the department rollup.
* [Read the cost-intelligence dashboards](/guides/cost-intelligence) — the
  operator walkthrough for the Insights → Costs page itself.
