Skip to main content

Spend, Margin and Import

Spend

Spend is the operational view of rated traffic: what has been charged so far this period, by tenant, by destination, by trunk, over time.

Vodia Billing 17

It also shows the ingest counters, which are the first place to look when CDRs are not appearing:

CounterMeaning
acceptedCDRs received and matched to a configured system
unknown_systemReceived but the system hash is not configured
unknown_domainThe domain is not a configured tenant
unknown_trunkThe trunk is not in the trunk registry
not_billableCorrectly recognised and correctly not charged

Unknown systems, domains and trunks are counted and dropped, never orphaned into a bill. A counter climbing is how you find a domain added on the PBX that nobody configured here.

Reclassifying a call

Spend → reclassify re-rates one leg against any destination on the tenant's sell plan. The "mark as international" case: a number the sheet priced as national that should not have been.

The original rating is preserved on the record and the change is audited. Reclassification is refused once the call has been invoiced, because the invoice is then the document and changing the call under it would make the two disagree.

Margin

Margin reports tenant by month profitability, split by charge basis, with loss-makers outlined. It surfaces the cost of included calls, which is the number that decides whether an inclusion allowance is priced correctly.

Margin is partner-only. Cost prices and margin never appear on an invoice, in an export file, or behind an invoice share link.

A blank cost produces a blank margin rather than a fabricated hundred percent.

Import

Import recovers days the webhook missed, from the PBX's own archive CSV files.

Every import runs as a dry run first, reporting per row:

ResultMeaning
already_ratedThis leg is already in the database. Nothing to do
newNot seen before; would be rated
unknown_*Same drop reasons as live ingest
not_billableRecognised, correctly not charged

Only after reviewing the dry run do you commit.

The verification that matters

Import an archive day that Vodia Billing already received by webhook, as a dry run. Almost everything must report already_rated.

If instead it reports a large number of new rows, the archive's timestamps are being interpreted in a different timezone from the webhook's. Every future recovery import would then double-bill or land calls in the wrong period.

Do this once during commissioning. It is the only way to settle the question.

Recovered calls land on the right invoice

Invoice periods are cut on when the call started, not when it was rated. An archive import run today for calls that happened last month puts those calls on last month's invoice, not on this month's.

If last month's invoice has already been finalized, the recovered calls will need a supplementary invoice or a manual adjustment. Recover promptly.

Rate Test

Rate Test answers "what would this number cost this tenant" without creating anything.

Enter a number, tenant and duration; it reports the matched rate row on both sell and cost sides, charge type, increments applied, connect fee and final price.

Run it after every rate sheet upload.

Worked examples

Reading the ingest counters

Each of these is a different problem with a different fix.

Nothing configured yet:

accepted:         0
unknown_system: 47 (a3f9c2d18b7e4506)

CDRs are arriving. The system ID is not set. → Systems.

A domain nobody configured:

accepted:       412
unknown_domain: 38 (branch.northwind-example.com)

The PBX has a domain that is not a tenant here. → Discover, then Tenants.

A trunk added on the PBX last week:

accepted:       412
unknown_trunk: 96 (carrier-tertiary)

→ Discover, then classify it under Trunks.

Everything working, and one trunk deliberately not billed:

accepted:     1,338
not_billable: 204
unknown_*: 0

204 legs were recognised and correctly not charged — internal calls over the transit trunk. This is what healthy looks like.

The one that fools people:

accepted:     1,338
not_billable: 1,338

Every call was recognised and none was charged. The carrier trunk is still classified non-billable. → Trunks.

Reclassifying a call

A number the sheet priced as national that should have been premium:

Leg 2026-08-14 09:41:02   ext 118 -> 18885550199   4m 12s
Rated as: US Toll Free included 0.00
Reclassify to: US Premium

Re-rated: US Premium 5.00 min x 2.50 + 0.75 connect = 13.25
Original rating preserved on the record.
Audited: cd@example.com, 2026-08-20 11:14

Refused once the call has been invoiced:

409 Conflict — leg is on INV-00151 (finalized 2026-09-01).

At that point the invoice is the document, and changing the call underneath it would make the two disagree. Issue a credit instead.

A margin report

hq.northwind-example.com   2026-08

basis sell cost margin %
talk_time 19.57 6.12 13.45 68.7%
recurring 696.00 286.20 409.80 58.9%
goods 89.00 61.00 28.00 31.5%
------ ------- -------
804.57 353.32 451.25 56.1%

Included calls: 318.00 min, cost 2.86, sell 0.00

The last line is the number that decides whether an inclusion allowance is priced correctly. Free to the customer is not free to you.

A loss-maker is outlined:

south.northwind-example.com  2026-08    642.15 sell / 701.40 cost / -59.25

A blank margin, which is not a bug

  basis          sell      cost    margin    %
talk_time 19.57 6.12 13.45 68.7%
recurring 696.00 — — —

The recurring rates have no cost configured. Blank means unknown, not free. A fabricated zero would have reported 100% margin on seats.

An archive import dry run — the good result

Dry run: pbx01-cdr-2026-08-19.csv
Rows read: 1,204
already_rated: 1,198
new: 6
unknown_trunk: 0
not_billable: 0

Six new legs from a brief webhook outage. This is what you want: the archive and the webhook agree on almost everything, which means their timestamps are being read in the same timezone.

The result that means stop

Dry run: pbx01-cdr-2026-08-19.csv
Rows read: 1,204
already_rated: 3
new: 1,201

The archive's timestamps are being interpreted in a different timezone from the webhook's. Committing this would double-bill the whole day. Do not commit; resolve the timezone question first.

This is why the dry run against an already-received day is a commissioning step rather than an optional check.