Postpaid Rating Engine: Disputes, Bundles and Shared Family Allowances
MediumAI-assisted coding · AI-Assisted Debugging
Postpaid Rating Engine: Disputes, Bundles and Shared Family Allowances
You've joined the billing team at a mobile carrier. The rating engine turns call detail records (CDRs) from the network switch into bill line items. Customer care has three open dispute tickets, and two roadmap features are queued behind them. All money is integer cents.
Codebase
| File | What it does |
|---|---|
rating/api.py | run(operations) entry point and the Dispatcher that validates and routes each operation |
rating/ledger.py | Biller: lines, CDR ingestion, bill queries |
rating/rating.py | rate_cdr: turns one CDR into bill items (calls, sms, data) |
rating/increments.py | billable_seconds: the billing-increment rule |
rating/peakcal.py | Peak / off-peak calendar and split_by_band |
rating/prefixes.py | International destination lookup |
rating/bundles.py | Monthly allowances (placeholder until Part 2) |
rating/models.py | Cdr, Item, Line |
rating/config.py | Tariff constants: rates, peak hours, increments, plans, international prefix table |
rating/errors.py | RatingError and every error code |
tests/ | Existing unit and end-to-end tests (some currently fail) |
How your code is run
run(operations: list[list]) -> list executes the operations in order against a fresh biller and returns exactly one result per operation. Errors are returned as "error: <code>" strings, never raised. An unknown operation name, or an operation that is not a list starting with a string, returns "error: unknown operation" / "error: invalid arguments" respectively.
| Operation | Arguments | Returns | Errors (checked in this order) |
|---|---|---|---|
line | line_id, plan_id | true | invalid arguments, unknown plan, line exists |
call | cdr_id, line_id, start, seconds, dest | true | see below |
sms | cdr_id, line_id, start, dest | true | see below |
data | cdr_id, line_id, start, megabytes | true | see below |
cdr_repeat | prefix, line_id, kind, start, step, count, cycle, dest | count | see below |
bill | line_id | {"total": cents, "items": [...]} | invalid arguments, unknown line |
total | line_id | the same total as bill, as an int | invalid arguments, unknown line |
CDR operations (call, sms, data) check in this order: invalid arguments (wrong argument count, ids that are not non-empty strings, start/seconds/megabytes that are not integers, seconds < 0, megabytes <= 0), unknown line, duplicate cdr, outside period, invalid destination, no international rate. A failed CDR changes nothing and does not reserve its cdr_id. cdr_ids are unique across the whole system.
startis in seconds since the start of the billing period, which is a Monday 00:00:00. A CDR is in the period if0 <= start < 2592000(30 days). A call that starts in the period is billed in full even if it runs past the end.destis"on"(same network),"off"(other network) or an international number:+followed by 7-15 digits.smsaccepts only"on"/"off". Any otherdestisinvalid destination; a well-formed international number whose prefix is not inconfig.INTERNATIONAL_RATESisno international rate.cdr_repeatimports a recurring usage pattern: recordi(foriin0..count-1) has id"<prefix>-<i padded to 5 digits>", starts atstart + i*step, and its quantity (seconds for acall, megabytes fordata) iscycle[i % len(cycle)]; ansmsignorescycle. Each record goes through exactly the same checks as the single-CDR operations; the first failing record's error is returned and records before it stay ingested.kindis"call","sms"or"data";destis""for data.1 <= count <= 100000andstep >= 0.cdr_ids sort by plain string comparison.
Bill items are [cdr_id, band, qty, bundled, cents]: qty units of band, of which bundled were covered by the plan allowance (Part 2; always 0 before that), costing cents. Bands are "peak" and "offpeak" (domestic voice, qty in seconds), "intl" (international voice, seconds), "sms" (qty 1) and "data" (qty in MB). A call with 0 seconds produces no items. bill lists the line's items ordered by (start, cdr_id) of their CDR, a call's segments in time order, and total is the sum of their cents.
Rating rules
- Billing increments. Domestic calls are
60/30, international calls60/60(config). A call ofdseconds is billed0ifd = 0,60if1 <= d <= 60, otherwise60 + 30 * ceil((d - 60) / 30)(international: step 60). Rounding is always up. - Peak / off-peak. Peak is Monday to Friday from 08:00:00 inclusive to 20:00:00 exclusive; everything else is off-peak. Domestic voice only; international calls are one flat rate and are never split.
- Calls that straddle a boundary are cut into maximal runs of one band: adjacent stretches in the same band (e.g. 23:50 Tuesday to 00:10 Wednesday, all off-peak) are *one* segment.
- Increment rounding applies once, to the whole call, never per segment. The call's real seconds are divided between its segments; the padding added by rule 1 (billable minus real seconds) is billed in the last segment, at that segment's rate.
- Voice cost of a segment is
qty * cents_per_minute / 60, rounded half-up to a whole cent ((qty * rate + 30) // 60), once per segment item. SMS costs its per-message rate, datamegabytes * DATA_RATE. Rates are inconfig.py. - International calls use the rate of the longest matching prefix in
INTERNATIONAL_RATES(+441624...is Isle of Man, not UK;+1242...is the Bahamas, not US/Canada).
Worked example (Monday 10:00 is 36000; Friday 19:58 is 417480)
[["line", "L1", "payg"],
["call", "c1", "L1", 36000, 90, "on"],
["call", "c3", "L1", 36100, 100, "+441624998877"],
["call", "c2", "L1", 417480, 250, "off"],
["bill", "L1"]]returns
[true, true, true, true,
{"total": 173, "items": [["c1","peak",90,0,12], ["c3","intl",120,0,116],
["c2","peak",120,0,30], ["c2","offpeak",150,0,15]]}](c2 ran 120 s in peak and 130 s off-peak; 250 s bills as 270, so the 20 s of padding lands in the off-peak segment.)
Part 1 - Three billing disputes (BILL-512, BILL-530, BILL-541)
Goal: make the engine follow the rating rules above. Records arrive for a line in (start, cdr_id) order, and nothing is bundled yet.
Tickets:
- BILL-512 (Finance audit): "Some 70-75 second calls are billed as 60 seconds, and calls that never connected (0 seconds) are billed a full minute."
- BILL-530 (Customer care): "A customer called at 19:58 on a Friday for four minutes and was billed far more than the same call on a Wednesday afternoon. Another got two items for a call that ran across midnight on a Tuesday, and a call that began at exactly 20:00:00 was billed as peak."
- BILL-541 (Customer care): "Calls to the Isle of Man and the Bahamas are billed at the UK and US rates."
tests/ contains unit tests for the helpers; several currently fail.
Part 2 - Monthly bundles (PRODUCT-88)
Goal: plans now carry a monthly allowance, and each line's usage draws on it before anything is charged.
Plans and allowances are in config.PLANS as (voice seconds, sms count, data MB). payg has none.
Rules
- Allowances cover domestic voice, sms and data only. International calls never use or reduce an allowance.
- Records are consumed in chronological order, by
(start, cdr_id). Part 2 guarantees records of a line arrive in that order. - Voice allowance is measured in billable seconds: rule 4's padding counts. A call's segments draw on it in time order. One allowance covers peak and off-peak time alike.
- The record that crosses the limit is split: the item's
bundledis the part still covered and itscentscharge only the remainingqty - bundled, with the half-up rounding applied to the charged part only. Once an allowance kind is exhausted, every further record of that kind is charged in full (bundled = 0). An sms is covered if at least one sms remains. Each kind (voice, sms, data) has its own balance. - Items still list
qtyas the full quantity of the segment, even when entirely bundled (cents = 0).
Example (lite plan: 600 voice seconds, 5 sms, 50 MB)
[["line", "L1", "lite"],
["call", "c1", "L1", 36000, 500, "on"],
["call", "c2", "L1", 37000, 120, "off"],
["data", "c3", "L1", 38000, 60],
["bill", "L1"]]returns
[true, true, true, true,
{"total": 48, "items": [["c1","peak",510,510,0], ["c2","peak",120,90,8], ["c3","data",60,50,40]]}](500 s bills as 510 and is fully covered; 90 s of allowance remain for c2, whose other 30 s cost 30 * 15 / 60 = 7.5, rounded to 8; 10 of the 60 MB are charged at 4 cents.)
Part 3 - Family pools and late CDRs (PRODUCT-102, OPS-3317)
Goal: lines can share one allowance, and CDRs may now arrive late.
line takes an optional third argument, group_id (a non-empty string; otherwise invalid arguments). All lines in the same group share a single allowance equal to their plan's, not one per line. Lines without a group keep a private allowance.
Rules
lineerrors become:invalid arguments,unknown plan,line exists, thenplan mismatchif the group already exists with a different plan. The first line of a group fixes its plan.- A group's records, from all its lines, are consumed in the global order
(start, line_id, cdr_id)(plain string comparison for ties). A line'sbillshows only its own items and total, but they depend on its sibling lines' usage. - The ordered-arrival guarantee of Part 2 is withdrawn. CDRs for any line may arrive in any order and may be older than ones already ingested (network re-sends, roaming partners). Every
billmust equal what you would get if all CDRs received so far had arrived sorted by the consumption order above, so a late CDR can change items and totals of later usage, including other lines' in the group. All errors from the CDR operations (includinginvalid destination) are still reported by the operation that carries the record, at ingestion. billis repeatable: asking twice without new CDRs returns the same answer. Hidden tests ingest thousands of records (viacdr_repeat) interleaved with many bills over many lines in onerun; the whole suite has a few seconds.
Example (lite plan; c2 arrives after c1)
[["line", "A", "lite", "fam"],
["line", "B", "lite", "fam"],
["call", "c1", "A", 1000, 400, "on"],
["bill", "A"],
["call", "c2", "B", 500, 300, "on"],
["bill", "A"],
["bill", "B"]]returns
[true, true, true,
{"total": 0, "items": [["c1","offpeak",420,420,0]]},
true,
{"total": 4, "items": [["c1","offpeak",420,300,4]]},
{"total": 0, "items": [["c2","offpeak",300,300,0]]}](c2 starts first, so it consumes 300 of the shared 600 seconds; c1 then has only 300 of its 420 covered and 120 s are charged at 2 cents a minute.)
Notes
config.pyis the signed-off tariff and is correct; so are the wire shapes inmodels.py.- You may use the AI assistant freely; you are expected to verify what it produces.
- Later parts must not break earlier ones; hidden tests re-check earlier behaviour.
Example 1
- Input:
- [["line","L1","payg"],["call","c1","L1",36000,90,"on"],["call","c3","L1",36100,100,"+441624998877"],["call","c2","L1",417480,250,"off"],["bill","L1"]]
- Output:
- [true,true,true,true,{"total":173,"items":[["c1","peak",90,0,12],["c3","intl",120,0,116],["c2","peak",120,0,30],["c2","offpeak",150,0,15]]}]
- Explanation
- The Friday 19:58 call runs 120 s in peak and 130 s off-peak; 250 s bills as 270 once for the whole call, so the 20 s of padding lands in the last (off-peak) segment. The Isle of Man number matches +441624, not +44.
Example 2
- Input:
- [["line","A","lite","fam"],["line","B","lite","fam"],["call","c1","A",1000,400,"on"],["bill","A"],["call","c2","B",500,300,"on"],["bill","A"]]
- Output:
- [true,true,true,{"total":0,"items":[["c1","offpeak",420,420,0]]},true,{"total":4,"items":[["c1","offpeak",420,300,4]]}]
- Explanation
- The late record c2 starts first, so it consumes 300 of the shared 600 seconds and c1 is only partly covered.
Guidelines
- Python 3 standard library only
- Money is integer cents; there is no floating-point money anywhere
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised
- Part 3 inputs contain thousands of CDRs interleaved with many bill queries