Generating Discrepancy Reports for Billing
This guide solves one exact task: take the classified reconciliation results for a broadcast day and aggregate them into a billing-ready report — one that tells the billing run exactly which spots to invoice, which to credit, which to route as make-good candidates, and leaves an immutable audit row behind every number. It is the reporting procedure that sits under As-Run Reconciliation and Discrepancy Handling, itself a subsystem of Broadcast Traffic Architecture & Taxonomy. The report is the artifact an auditor actually reads: it is where a SHORT airing becomes a pro-rata credit line, where a MISSING spot becomes a zero-revenue row plus a make-good obligation, and where the station’s claim that it billed only what aired is either provable or not.
The core requirement is that the report be a faithful, deterministic roll-up of the reconciliation — every credit traceable to the discrepancy that caused it, every make-good traceable to the spot it owes, and the totals reproducible when the same results are replayed months later during a billing dispute. Nothing in this step re-adjudicates a classification; it only aggregates, prices, and records. Those inputs come straight from detecting as-run vs scheduled discrepancies, and the billing keys they carry must already agree with billing code normalization.
Prerequisites
- Python 3.11+ — required for the
X | Noneunion syntax,enummembers inmatch, and timezone-awaredatetime.now(timezone.utc)used below. - Pydantic v2 — pin exactly (
pydantic==2.7.1); the report models reuse the v2 validators that keep every timestamp UTC-aware. - Reconciled results as input — a list of classified results (spot id, discrepancy type, billable flag, billable duration) produced upstream per the parent reconciliation guide.
- A rate lookup — booked rate per spot keyed on the normalized billing code, so credits and invoiced amounts are priced against the same code the order was sold on, consistent with billing code normalization.
- A writable, append-only report sink — an object-store prefix or an immutable table; audit rows are written once and never mutated, which is what an FCC public-file or SOC 2 review expects.
- Read-only access to the reconciliation output — the report builder derives everything from its inputs and must never edit a classification to make the totals balance.
Step-by-Step Implementation
The builder runs as a pure fold over the reconciliation results: price each result, fan it into one of three buckets — invoice line, credit line, or make-good candidate — accumulate the day’s totals, and emit an immutable report with one audit row per spot. The flow below shows the fan-out.
Figure — The report builder prices each reconciliation result and fans it into invoice lines, credit lines, or make-good candidates, then rolls all three into day totals and one immutable audit row per spot.
Step 1 — Model the report rows and the rate lookup
Goal: fix the typed records that carry a priced result into the report, and the rate lookup that prices them. Amounts are integer cents, never floats, so a day’s totals reconcile exactly.
from __future__ import annotations
import logging
from datetime import datetime, timezone
from enum import Enum
from pydantic import BaseModel, Field, field_validator
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
)
logger = logging.getLogger("traffic.reconcile.report")
class DiscrepancyType(str, Enum):
AIRED_AS_SCHEDULED = "aired_as_scheduled"
SHIFTED = "shifted"
SHORT = "short"
LONG = "long"
PREEMPTED = "preempted"
MISSING = "missing"
UNMATCHED_ASRUN = "unmatched_asrun"
class LineKind(str, Enum):
INVOICE = "invoice"
CREDIT = "credit"
MAKE_GOOD = "make_good"
INVESTIGATE = "investigate" # UNMATCHED_ASRUN — never billed
class ReconciledResult(BaseModel):
spot_id: str
house_number: str
billing_code: str # normalized; the join key to the rate card
discrepancy_type: DiscrepancyType
billable: bool
expected_duration_ms: int = Field(gt=0)
billable_duration_ms: int = Field(ge=0)
class ReportRow(BaseModel):
spot_id: str
billing_code: str
line_kind: LineKind
discrepancy_type: DiscrepancyType
amount_cents: int # signed: credits are negative
make_good_owed: bool = False
note: str = ""
class BillingReport(BaseModel):
channel: str
broadcast_day: str # ISO date the traffic system assigned
rows: list[ReportRow]
invoiced_cents: int
credited_cents: int # stored as a negative total
make_goods_owed: int
generated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc))
@field_validator("generated_at")
@classmethod
def _utc(cls, v: datetime) -> datetime:
if v.tzinfo is None:
raise ValueError("generated_at must be timezone-aware (UTC)")
return v.astimezone(timezone.utc)
Expected result: the models import cleanly and LineKind gives the report exactly four terminal buckets, so no result can fall through unclassified.
Step 2 — Price a single result into a report row
Goal: turn one reconciled result into one priced row. Full-rate airings invoice the booked amount; a SHORT airing invoices the aired fraction and emits nothing to revenue it did not earn; MISSING and PREEMPTED invoice zero and flag a make-good.
def price_row(result: ReconciledResult, booked_cents: int) -> ReportRow:
dtype = result.discrepancy_type
# Pro-rata factor for SHORT: aired length over booked length, never > 1.
frac = min(result.billable_duration_ms / result.expected_duration_ms, 1.0)
match dtype:
case (DiscrepancyType.AIRED_AS_SCHEDULED
| DiscrepancyType.SHIFTED
| DiscrepancyType.LONG):
# LONG bills the booked length — an overrun is never invoiced.
return ReportRow(spot_id=result.spot_id,
billing_code=result.billing_code,
line_kind=LineKind.INVOICE, discrepancy_type=dtype,
amount_cents=booked_cents,
note="full-rate airing")
case DiscrepancyType.SHORT:
aired = round(booked_cents * frac)
return ReportRow(spot_id=result.spot_id,
billing_code=result.billing_code,
line_kind=LineKind.INVOICE, discrepancy_type=dtype,
amount_cents=aired,
note=f"aired {frac:.0%} of booked length")
case DiscrepancyType.MISSING | DiscrepancyType.PREEMPTED:
return ReportRow(spot_id=result.spot_id,
billing_code=result.billing_code,
line_kind=LineKind.MAKE_GOOD, discrepancy_type=dtype,
amount_cents=0, make_good_owed=True,
note="no revenue; make-good candidate")
case _: # UNMATCHED_ASRUN
return ReportRow(spot_id=result.spot_id,
billing_code=result.billing_code,
line_kind=LineKind.INVESTIGATE, discrepancy_type=dtype,
amount_cents=0, note="aired with no schedule row")
Expected result: a SHORT result with billable_duration_ms=26000, expected_duration_ms=30000, and a booked 5000 cents returns an invoice row of 4333 cents with the note aired 87% of booked length.
Step 3 — Emit the paired credit line for a short airing
Goal: for every SHORT airing, write the shortfall as an explicit negative credit row so the report shows both what was invoiced and what was withheld — the two must sum to the booked amount, which is what makes the credit defensible.
def credit_for_short(result: ReconciledResult, booked_cents: int,
invoiced_cents: int) -> ReportRow:
# The credit is the exact complement of the pro-rata invoice, so
# invoice + credit == booked and no cents are invented or lost.
shortfall = invoiced_cents - booked_cents # negative by construction
logger.info("%s | credit=%dcents | short airing shortfall",
result.spot_id, shortfall)
return ReportRow(spot_id=result.spot_id, billing_code=result.billing_code,
line_kind=LineKind.CREDIT,
discrepancy_type=DiscrepancyType.SHORT,
amount_cents=shortfall,
note="pro-rata credit for under-delivered length")
Expected log line: 2026-07-17T05:20:03+00:00 | INFO | traffic.reconcile.report | SP-2026-0716-0442 | credit=-667cents | short airing shortfall — the exact complement of the 4333-cent invoice against a 5000-cent booking.
Step 4 — Fold the results into an immutable report
Goal: compose the pieces into a deployable ReportBuilder that prices every result, accumulates the day totals, and returns one immutable BillingReport. The builder never mutates its inputs and never edits a row after appending it.
class ReportBuilder:
def __init__(self, rate_card: dict[str, int]) -> None:
# rate_card maps a normalized billing_code -> booked rate in cents.
self.rate_card = rate_card
def build(self, channel: str, broadcast_day: str,
results: list[ReconciledResult]) -> BillingReport:
rows: list[ReportRow] = []
invoiced = credited = make_goods = 0
for result in results:
booked = self.rate_card.get(result.billing_code)
if booked is None:
# Fail closed: an unpriced code cannot be invoiced silently.
logger.error("%s | ERR_RATE_NOT_FOUND | code=%s",
result.spot_id, result.billing_code)
rows.append(ReportRow(
spot_id=result.spot_id, billing_code=result.billing_code,
line_kind=LineKind.INVESTIGATE,
discrepancy_type=result.discrepancy_type,
amount_cents=0, note="no rate on card; held for review"))
continue
row = price_row(result, booked)
rows.append(row)
if row.line_kind is LineKind.INVOICE:
invoiced += row.amount_cents
if row.make_good_owed:
make_goods += 1
# A SHORT invoice always spawns its complementary credit row.
if result.discrepancy_type is DiscrepancyType.SHORT:
credit = credit_for_short(result, booked, row.amount_cents)
rows.append(credit)
credited += credit.amount_cents
logger.info("%s | report built | invoiced=%d credited=%d make_goods=%d",
broadcast_day, invoiced, credited, make_goods)
return BillingReport(channel=channel, broadcast_day=broadcast_day,
rows=rows, invoiced_cents=invoiced,
credited_cents=credited, make_goods_owed=make_goods)
Expected log line: 2026-07-17T05:20:04+00:00 | INFO | traffic.reconcile.report | 2026-07-16 | report built | invoiced=1284500 credited=-667 make_goods=3 — a day’s roll-up with three spots owed a make-good.
Verification & Testing
Correct behaviour rests on two properties: conservation (a SHORT spot’s invoice plus its credit equals the booked rate, so no revenue is invented or lost) and separation (only billable types produce invoice revenue). Both are assertable against fixture data.
RATE_CARD = {"RCPRIME30": 5_000, "H7QSR030": 5_000}
builder = ReportBuilder(rate_card=RATE_CARD)
results = [
ReconciledResult(spot_id="SP-1", house_number="H1", billing_code="RCPRIME30",
discrepancy_type=DiscrepancyType.SHORT, billable=True,
expected_duration_ms=30_000, billable_duration_ms=26_000),
ReconciledResult(spot_id="SP-2", house_number="H2", billing_code="H7QSR030",
discrepancy_type=DiscrepancyType.MISSING, billable=False,
expected_duration_ms=30_000, billable_duration_ms=0),
]
report = builder.build("CH7", "2026-07-16", results)
# 1. Conservation: the SHORT spot's invoice + credit == its booked rate.
inv = next(r for r in report.rows
if r.spot_id == "SP-1" and r.line_kind is LineKind.INVOICE)
cred = next(r for r in report.rows
if r.spot_id == "SP-1" and r.line_kind is LineKind.CREDIT)
assert inv.amount_cents + cred.amount_cents == 5_000
# 2. Separation: the MISSING spot invoices nothing and owes a make-good.
mg = next(r for r in report.rows if r.spot_id == "SP-2")
assert mg.line_kind is LineKind.MAKE_GOOD
assert mg.amount_cents == 0 and mg.make_good_owed
assert report.make_goods_owed == 1
Because build is a pure fold over its inputs with integer arithmetic, the totals are stable across machines and runs — replaying an archived day’s reconciliation reproduces byte-identical amounts. Run the suite in CI against a golden fixture so a pricing change that would shift a historical invoice fails the build instead of shipping a silent restatement.
Edge Cases & Failure Handling
- Missing rate on the card. A billing code with no entry in the rate lookup cannot be priced. The builder fails closed, routes the row to
INVESTIGATEwith a zero amount, and logsERR_RATE_NOT_FOUNDrather than guessing a rate — an invented number is exactly the kind of unsupported charge an audit rejects. Fix the gap upstream with billing code normalization and re-run; the deterministic fold reproduces the corrected total. - Make-good already fulfilled. A
MISSINGspot that was recovered later in the day still appears here as a make-good candidate, because reporting does not know about recovery. Hand the make-good rows to make-good routing for preemptions, which owns fulfilment state and closes the obligation on the correlation key — reporting stays a pure snapshot of the day and never double-credits a recovered spot. - Orphan as-run event. An
UNMATCHED_ASRUNresult is an airing with no schedule row, often a last-minute manual insert or a house-number typo. It is routed toINVESTIGATE, never billed, and surfaced in the report so the traffic desk reconciles it against the trafficking log. If it turns out to be a legitimate airing, correct the schedule and re-run rather than adding a revenue row by hand.
FAQ
Why store amounts as integer cents instead of decimals?
Because a billing report has to reconcile to the penny, and floating-point dollars accumulate rounding error across thousands of spots. Integer cents make the conservation property — invoice plus credit equals booked — an exact equality the test suite can assert. The only rounding happens once, at the pro-rata calculation for a SHORT airing, and the paired credit is computed as its exact complement so the two always sum back to the booked rate.
Does the report decide who gets a make-good, or just flag it?
It only flags. Every MISSING and PREEMPTED result becomes a make-good candidate row with zero revenue, but the report is a snapshot and does not know whether the spot was already recovered. Fulfilment — finding a compliant replacement window and closing the obligation — belongs to make-good routing for preemptions, which owns that state. Keeping reporting and routing separate means the report stays deterministic and never double-credits a recovered spot.
How does a preempted spot appear differently from a missing one on the invoice?
On the invoice itself they look the same — both are zero-revenue make-good rows — but the discrepancy_type on the audit row preserves the distinction, which matters when the make-good is negotiated. A PREEMPTED spot displaced by an EAS emergency interrupt or a network break carries a clear, defensible cause; a bare MISSING is a service failure the station has to explain. Retaining the type on every row is what lets a later dispute reconstruct why a spot did not bill.
Why keep an audit row for spots that billed normally?
Because an audit proves completeness, not just exceptions. If the report only recorded discrepancies, an auditor could not tell a fully-aired spot from one that was silently dropped before reporting. Writing one immutable row per spot — including every AIRED_AS_SCHEDULED — means the report row count equals the scheduled spot count, and each row traces back through its spot_id and billing_code to the identifiers defined in the canonical traffic field data dictionary.
Related
- As-Run Reconciliation and Discrepancy Handling — the parent guide that produces the classified results this report aggregates and prices.
- Detecting As-Run vs Scheduled Discrepancies — the matching and classification pass that decides each result’s discrepancy type before it reaches the report.
- Standardizing Billing Codes Across Traffic Systems — the billing code normalization that keys the rate card so every credit and invoice prices against the sold code.