Telecom Billing System Testing: Strategies for CDR, Rating, and Invoice Accuracy
Telecom billing is where network events become revenue. A single rating error silently applied to millions of CDRs (Call Detail Records) can cost or lose millions of dollars before discovery. Billing system testing is not glamorous, but it is one of the highest-impact testing disciplines in the industry. Errors that make it to invoices generate customer complaints, regulatory scrutiny, and churn.
Billing System Architecture
A telecom billing system typically consists of these pipeline stages:
Network Events → Mediation → Rating Engine → Billing → Invoicing
│ │ │ │ │
Switches CDR gen Tariff calc Account PDF/EDI
PCRF/OCS Normalization Discount balance generation
IMS nodes Dedup Tax cycle
Aggregation BundlingEach stage has distinct test concerns:
- Mediation: Are all events captured? Are duplicates correctly suppressed? Is normalization lossless?
- Rating: Is the correct tariff applied? Are discounts, bundles, and promotions applied correctly?
- Billing: Do account balances update correctly? Are credit limits enforced?
- Invoicing: Does the invoice math add up? Are taxes correct by jurisdiction?
CDR (Call Detail Record) Testing
CDRs are the atomic unit of billing. A voice CDR contains: calling/called number, start time, duration, network node, quality indicators. A data CDR contains: MSISDN, IMSI, bytes up/down, session start/end, APN.
CDR Generation Validation
import pandas as pd
from datetime import datetime, timedelta
import random
def generate_test_cdrs(count=1000, output_file="test_cdrs.csv"):
"""Generate synthetic CDRs for testing mediation and rating."""
cdrs = []
base_time = datetime(2024, 1, 15, 8, 0, 0)
msisdns = [f"447{random.randint(700000000, 799999999)}" for _ in range(50)]
for i in range(count):
call_type = random.choice(["voice", "sms", "data"])
start = base_time + timedelta(seconds=random.randint(0, 86400))
if call_type == "voice":
duration = random.randint(5, 3600)
cdr = {
"record_type": "MOC",
"msisdn": random.choice(msisdns),
"called_number": f"+3531{random.randint(1000000, 9999999)}",
"start_time": start.isoformat(),
"duration_seconds": duration,
"network_type": random.choice(["2G", "3G", "4G", "5G"]),
"roaming": random.choice([0, 0, 0, 1]), # 25% roaming
"bytes_uplink": 0,
"bytes_downlink": 0,
}
elif call_type == "data":
duration = random.randint(60, 7200)
bytes_dl = random.randint(1024, 100 * 1024 * 1024)
cdr = {
"record_type": "GPRS",
"msisdn": random.choice(msisdns),
"called_number": "",
"start_time": start.isoformat(),
"duration_seconds": duration,
"network_type": random.choice(["4G", "5G"]),
"roaming": random.choice([0, 0, 0, 1]),
"bytes_uplink": int(bytes_dl * 0.1),
"bytes_downlink": bytes_dl,
}
else: # SMS
cdr = {
"record_type": "SMS-MO",
"msisdn": random.choice(msisdns),
"called_number": f"+3531{random.randint(1000000, 9999999)}",
"start_time": start.isoformat(),
"duration_seconds": 0,
"network_type": "4G",
"roaming": 0,
"bytes_uplink": 0,
"bytes_downlink": 0,
}
cdrs.append(cdr)
df = pd.DataFrame(cdrs)
df.to_csv(output_file, index=False)
return df
def test_cdr_deduplication(mediation_input, mediation_output):
"""Verify mediation layer correctly deduplicates CDRs."""
input_df = pd.read_csv(mediation_input)
output_df = pd.read_csv(mediation_output)
# Create duplicate CDRs in input (simulating network switch retry)
duplicates = input_df.sample(n=50)
input_with_dups = pd.concat([input_df, duplicates])
input_with_dups.to_csv(mediation_input, index=False)
# Re-run mediation (system under test)
run_mediation(mediation_input, mediation_output)
output_df = pd.read_csv(mediation_output)
# Output should have same count as original input (no duplicates)
expected_count = len(input_df)
actual_count = len(output_df)
assert actual_count == expected_count, \
f"Deduplication failed: input={expected_count}, output={actual_count} " \
f"(expected no duplicates)"
print(f"PASS: Deduplication correct — {actual_count} records after removing {len(duplicates)} duplicates")Mediation Normalization Testing
def test_duration_normalization():
"""Billing durations must be normalized to billing unit (typically 60s for voice)."""
test_cases = [
# (actual_duration_s, billing_unit_s, expected_billed_units)
(45, 60, 1), # Less than 1 unit → still billed 1 unit (rounding up)
(60, 60, 1), # Exactly 1 unit → 1 unit
(61, 60, 2), # 1 second over → 2 units
(120, 60, 2), # Exactly 2 units → 2 units
(0, 60, 0), # Zero duration (failed call) → 0 units
]
for actual, unit, expected in test_cases:
billed = math.ceil(actual / unit) if actual > 0 else 0
assert billed == expected, \
f"Duration {actual}s with {unit}s billing unit: expected {expected} units, got {billed}"
print("PASS: Duration normalization correct for all test cases")Rating Engine Accuracy Testing
The rating engine applies tariffs to normalized CDRs. This is where the most financially significant errors occur.
from decimal import Decimal, ROUND_HALF_UP
import json
def load_tariff(tariff_file):
with open(tariff_file) as f:
return json.load(f)
def calculate_expected_charge(cdr, tariff):
"""
Reference rating implementation for test validation.
Compare this against the production rating engine output.
"""
charge = Decimal("0.00")
if cdr["record_type"] == "MOC":
# Voice call rating
if cdr["roaming"]:
rate = Decimal(str(tariff["roaming"]["voice_per_minute"]))
else:
# Check if within free bundle allowance
if cdr.get("bundle_minutes_remaining", 0) > 0:
rate = Decimal("0.00")
else:
rate = Decimal(str(tariff["domestic"]["voice_per_minute"]))
billed_minutes = (cdr["duration_seconds"] + 59) // 60 # Round up
charge = rate * Decimal(str(billed_minutes))
elif cdr["record_type"] == "GPRS":
# Data rating: typically per MB
bytes_total = cdr["bytes_uplink"] + cdr["bytes_downlink"]
mb_consumed = Decimal(str(bytes_total)) / Decimal("1048576")
if cdr["roaming"]:
rate = Decimal(str(tariff["roaming"]["data_per_mb"]))
charge = (mb_consumed * rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
else:
# Domestic data may be free within bundle
charge = Decimal("0.00")
elif cdr["record_type"] == "SMS-MO":
if cdr["roaming"]:
charge = Decimal(str(tariff["roaming"]["sms_per_message"]))
else:
charge = Decimal("0.00") # Assuming free domestic SMS in bundle
return charge
def verify_rating_accuracy(rated_cdrs_file, tariff_file, tolerance=0.001):
"""
Compare production rating engine output against reference implementation.
tolerance: acceptable deviation as fraction (0.001 = 0.1%)
"""
tariff = load_tariff(tariff_file)
rated_df = pd.read_csv(rated_cdrs_file)
mismatches = []
for _, row in rated_df.iterrows():
cdr = row.to_dict()
expected = calculate_expected_charge(cdr, tariff)
actual = Decimal(str(cdr["charge"]))
if expected == 0 and actual == 0:
continue
if expected > 0:
deviation = abs(actual - expected) / expected
else:
deviation = abs(actual - expected)
if deviation > Decimal(str(tolerance)):
mismatches.append({
"cdr_id": cdr.get("cdr_id"),
"type": cdr["record_type"],
"expected": float(expected),
"actual": float(actual),
"deviation_pct": float(deviation * 100)
})
if mismatches:
print(f"RATING MISMATCHES: {len(mismatches)}/{len(rated_df)}")
for m in mismatches[:10]:
print(f" CDR {m['cdr_id']} ({m['type']}): expected={m['expected']:.4f}, "
f"actual={m['actual']:.4f} ({m['deviation_pct']:.2f}% deviation)")
return False
print(f"PASS: All {len(rated_df)} CDRs rated correctly within {tolerance*100:.1f}% tolerance")
return TrueInvoice Reconciliation Testing
Invoices must be mathematically correct and reconcilable back to CDRs.
def reconcile_invoice(invoice_file, rated_cdrs_file, account_id):
"""Verify invoice total matches sum of rated CDRs for the billing period."""
invoice = parse_invoice(invoice_file) # Returns {account_id, period, line_items, total, tax, grand_total}
cdrs = pd.read_csv(rated_cdrs_file)
account_cdrs = cdrs[cdrs["account_id"] == account_id]
period_start = pd.Timestamp(invoice["period_start"])
period_end = pd.Timestamp(invoice["period_end"])
period_cdrs = account_cdrs[
(pd.to_datetime(account_cdrs["start_time"]) >= period_start) &
(pd.to_datetime(account_cdrs["start_time"]) < period_end)
]
cdr_total = period_cdrs["charge"].sum()
invoice_charges = sum(item["amount"] for item in invoice["line_items"])
# Allow 1 cent rounding tolerance
discrepancy = abs(cdr_total - invoice_charges)
assert discrepancy < 0.01, \
f"Invoice discrepancy: CDR total={cdr_total:.2f}, " \
f"Invoice charges={invoice_charges:.2f}, " \
f"Difference={discrepancy:.4f}"
# Verify tax calculation
expected_tax = invoice_charges * get_tax_rate(invoice["jurisdiction"])
tax_discrepancy = abs(invoice["tax"] - expected_tax)
assert tax_discrepancy < 0.01, \
f"Tax calculation error: expected={expected_tax:.2f}, actual={invoice['tax']:.2f}"
print(f"PASS: Invoice reconciled — {len(period_cdrs)} CDRs, total={invoice_charges:.2f}")Roaming Billing Testing (TAP Files)
Roaming billing uses TAP (Transferred Account Procedures) files exchanged between operators. Testing requires generating synthetic TAP files and verifying they are correctly processed.
def generate_tap3_file(cdrs, home_operator_tadig, visited_operator_tadig, output_file):
"""
Generate a TAP3 (GSM Association TD.57) file from CDR data.
TAP3 is ASN.1-encoded; this example uses the tap3-python library.
"""
from tap3 import TAP3File, MobileOrigCall, BasicCallInformation
from datetime import datetime
tap = TAP3File()
tap.sender_identification = visited_operator_tadig
tap.recipient_identification = home_operator_tadig
tap.file_creation_timestamp = datetime.utcnow()
for cdr in cdrs:
if not cdr["roaming"]:
continue
call = MobileOrigCall()
call.calling_number = cdr["msisdn"]
call.called_number = cdr["called_number"]
call.call_event_start_timestamp = cdr["start_time"]
call.total_call_event_duration = cdr["duration_seconds"]
call.charge_information.charge = int(float(cdr["charge"]) * 100) # In cents
call.charge_information.currency = "EUR"
tap.call_events.append(call)
tap.write(output_file)
print(f"Generated TAP3 file: {len(tap.call_events)} roaming CDRs → {output_file}")
def test_tap_file_processing(tap_file, expected_roaming_revenue):
"""Verify TAP file is correctly ingested and revenue matches."""
# Submit TAP file to billing system (system-specific API)
submit_tap_file(tap_file)
# Wait for processing (may take minutes in production)
import time
time.sleep(30)
# Verify ingested revenue
processed_revenue = get_roaming_revenue_for_period(
operator="TEST_VISITED",
period_start="2024-01-15",
period_end="2024-01-16"
)
tolerance = expected_roaming_revenue * 0.001 # 0.1% tolerance
assert abs(processed_revenue - expected_roaming_revenue) < tolerance, \
f"Roaming revenue mismatch: expected={expected_roaming_revenue:.2f}, " \
f"processed={processed_revenue:.2f}"
print(f"PASS: TAP file processed correctly — roaming revenue={processed_revenue:.2f}")Performance Testing for Peak CDR Volumes
Billing systems must handle end-of-month peaks without falling behind real-time.
# CDR throughput test using Apache Kafka as mediation input
# Test: can rating engine process 10,000 CDRs/second?
# Generate test CDRs at target rate
python3 generate_cdrs.py --count 3600000 --output test_cdrs.csv # 1 hour of CDRs
# Push to Kafka at controlled rate
python3 << 'EOF'
from kafka import KafkaProducer
import csv
import time
import json
producer = KafkaProducer(
bootstrap_servers='localhost:9092',
value_serializer=lambda v: json.dumps(v).encode()
)
TARGET_RATE = 10000 # CDRs per second
batch_size = 1000
interval = batch_size / TARGET_RATE
with open("test_cdrs.csv") as f:
reader = csv.DictReader(f)
batch_start = time.monotonic()
count = 0
for row in reader:
producer.send("cdrs", value=row)
count += 1
if count % batch_size == 0:
producer.flush()
elapsed = time.monotonic() - batch_start
sleep_time = interval - elapsed
if sleep_time > 0:
time.sleep(sleep_time)
batch_start = time.monotonic()
print(f"Sent {count} CDRs, rate: {batch_size/max(elapsed,0.001):.0f}/s")
producer.flush()
print(f"Completed: {count} CDRs sent")
EOF
# Monitor rating engine consumer lag
watch -n 2 "kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
--describe --group billing-rating-engine | grep cdr"Key Takeaways
- CDR deduplication testing is critical — network switches often retry event records, and duplicate charges are a common regulatory complaint
- Build a reference rating implementation and run it against production outputs; financial precision requires Decimal arithmetic, not floating point
- Invoice reconciliation must trace from every line item back to individual CDRs — a reconciliation gap of even 0.01% at scale is a significant revenue leak
- TAP file testing for roaming requires generating syntactically valid ASN.1-encoded files; malformed TAP files are silently rejected by most inter-operator clearing houses
- Performance test at 2-3x peak CDR volume — billing systems that fall behind real-time during month-end peaks create delayed invoices and customer experience problems