Claims Adjudication: Deductibles, Reversals and Coordination of Benefits
HardAI-assisted coding · AI-Assisted Debugging
Claims Adjudication: Deductibles, Reversals and Coordination of Benefits
You've joined the claims platform team at a health insurer. The adjudication engine prices medical claims: for every service line it decides how much the plan pays and how much the member owes (copay, deductible, coinsurance), and it tracks how far each family has progressed toward its deductible and out-of-pocket maximum. Finance's year-end audit found members being charged the wrong amounts, and the roadmap behind the fixes is bigger: claims arrive late, get corrected and reversed, members have a second insurer, and December correction runs are slow. All money is integer cents.
Codebase
| File | What it does |
|---|---|
claims/api.py | run(operations) entry point and the Dispatcher that routes each operation |
claims/service.py | ClaimsService: the facade behind every operation |
claims/adjudicator.py | ClaimAdjudicator: prices a claim's lines and charges the accumulators |
claims/benefits.py | price_line: how one service line splits between member and plan |
claims/accumulators.py | Deductible and out-of-pocket accumulators per (family, plan, plan year) |
claims/plans.py | Plan definition, validation (parse_plan) and PlanRegistry |
claims/catalog.py | Plans that can be registered by name (gold, silver, fiscal, ...) |
claims/directory.py | Who is enrolled, in which family policy |
claims/models.py | Claim / Line objects and claim validation |
claims/claim_log.py | Remembers submitted claims |
claims/reports.py | Builds every wire-format dict a caller sees |
claims/dates.py | ISO date validation and plan_year |
claims/money.py | Cent helpers, including coinsurance_share |
claims/storm.py | Year-end volume simulator behind the storm operation (Part 4) |
claims/errors.py, claims/config.py | Error codes and wire constants |
tests/ | Example tests (some currently fail) |
How your code is run
run(operations: list[list]) -> list executes the operations in order against a fresh engine and returns one result per operation. Every operation is [name, *args]. Errors are returned as "error: <code>", never raised. An empty or non-list operation, a non-string name, or a wrong number of arguments returns "error: invalid arguments"; an unknown name returns "error: unknown operation".
| Operation | Arguments | Returns | Errors (checked in this order) |
|---|---|---|---|
plan | a plan definition (dict) or a catalog name | the plan id | unknown plan (name not in the catalog), invalid plan, duplicate plan |
enroll | member, family, plan_id | true | invalid arguments (any is not a non-empty string), unknown plan, member enrolled |
submit | claim | the claim result (below) | invalid claim, duplicate claim, unknown member |
intake | claim | [member_owes, plan_pays, secondary_pays] | same as submit; processing is identical |
claim | claim_id | the claim's current result | invalid arguments, unknown claim |
totals | claim_id | [member_owes, plan_pays, secondary_pays] of the current result ("reversed" for a reversed claim) | same as claim |
status | member, date | accumulators for the plan year containing date | invalid arguments (member not a non-empty string, or date not a real ISO date), unknown member |
reverse, adjust | Part 2 | ||
cob | Part 3 | ||
storm | Part 4 |
secondary_pays is always 0 until Part 3. A claim is {"id": str, "member": str, "lines": [{"code": str, "billed": int, "date": "YYYY-MM-DD"}, ...]}; it is valid when id and member are non-empty strings and lines has 1 to 50 entries, each with a non-empty code, an integer billed > 0 (booleans are not integers here) and a real calendar date. Line numbers are 1-based positions in the submitted list. submit and intake are all-or-nothing: an erroring claim changes nothing.
Plans. A plan definition is a dict with id, year_start_month (1-12), coinsurance_pct (0-100, the percentage the *member* pays), deductible and oop_max (each {"individual", "family"} in cents, family
>= individual), copays (category -> {"amount", "deductible": bool}) and fee_schedule (procedure code -> {"allowed", "category"}, allowed > 0). Read claims/catalog.py for the named plans used in examples.
Policies and plan years. enroll puts a member in a *family* under one plan. Deductible and out-of-pocket progress is tracked per (family id, plan id, plan year): one set of accumulators shared by everyone with that family id and plan. A plan year is named by the calendar year in which it *starts*: for year_start_month 7, 2026-03-10 is in plan year 2025 and 2026-07-01 starts plan year 2026. Accumulators of different plan years never interact.
Claim result (submit, claim):
{"claim": "C1", "member": "ana", "status": "adjudicated",
"lines": [{"line": 3, "code": "73721", "date": "2025-01-20", "status": "paid", "billed": 60000, "allowed": 40000,
"copay": 0, "deductible": 40000, "coinsurance": 0, "plan_pays": 0, "member_owes": 40000}],
"billed": 60000, "allowed": 40000, "plan_pays": 0, "member_owes": 40000}Lines are listed in *processing order* (see Part 1); line is the submitted line number. The claim totals are sums over its lines. Status result (status): {"plan": id, "plan_year": int, "member": {"deductible", "oop"}, "family": {"deductible", "oop"}}, all zero if nothing has been charged yet in that plan year.
Part 1 — Audit: the member cost-sharing math is wrong (FIN-3318)
Goal: make submit/claim/status follow the benefit rules below. Finance's findings, in their words: members are "charged more than their allowed amount", "charged copays against their deductible", "told the deductible is gone when it isn't, or still owing when the family's is met", "rounded against on half-cents", "charged cost sharing after the out-of-pocket maximum", and fiscal-year members whose deductible "resets in January instead of July". Find and fix every defect behind them; the symptoms are spread over several modules.
Benefit rules. Price the lines of a claim one at a time in order of service date, then line number (ties on date are broken by line number). For each line, with the member's plan and the accumulators for the policy and the plan year of the line's date:
- Not covered. A code missing from the plan's fee schedule is *denied*:
status"denied",allowed,copay,deductible,coinsurance,plan_paysall 0, andmember_owesequalsbilled. A denied line changes no accumulator. - Allowed amount =
min(billed, scheduled allowed). Anything billed above it is a provider write-off: nobody owes it. - Copay. If the code's category has a copay rule,
copay = min(rule amount, allowed)(otherwise 0) andremaining = allowed - copay. - Copay-only categories. If the rule has
"deductible": false, the line ends here: deductible and coinsurance are 0 and the plan paysremaining. (A rule with"deductible": true, and a category without a rule, continue.) - Deductible.
deductible = min(remaining, deductible room). Deductibles are *embedded*: the room ismax(0, min(individual deductible - member's deductible used, family deductible - family's deductible used)). The family figure is the sum over everyone with that family and plan. When either limit is used up, the member owes no more deductible. - Coinsurance = the member's
coinsurance_pctpercent ofremaining - deductible, rounded half a cent up:(amount * pct + 50) // 100. - Out-of-pocket maximum.
cost_share = copay + deductible + coinsurance. The out-of-pocket room ismax(0, min(individual oop_max - member's oop used, family oop_max - family's oop used)). Ifcost_shareexceeds it, the plan absorbs the excess, taken off coinsurance first, then deductible, then copay. Once the family room is 0, nobody in the family owes anything (copays included). member_owes = copay + deductible + coinsurance(after step 7) andplan_pays = allowed - member_owes.- Accumulators. Only the deductible actually charged counts toward the member's and the family's deductible; copays and coinsurance do not. The whole
member_owes(copay + deductible + coinsurance) counts toward the member's and the family's out-of-pocket.
At this stage claims are charged to the accumulators as they are submitted (that changes in Part 2). tests/test_benefits.py and tests/test_api.py show the style; at least one currently fails.
Worked example. plan "gold" (20% coinsurance, 1,000 family deductible, 2,500 out-of-pocket maximum, office visit copay 150.00 2025-02-03, 80053 600.00 2025-01-20, 73721 $400.00 2025-03-01`:
- processing order: line 3 (01-20), line 1 (02-03), line 2 (02-03), line 4 (03-01);
- line 3: allowed 400, plan pays $0;
- line 1: allowed 30, plan pays $90; the copay does not touch the deductible;
- line 2: allowed 45 schedule), deductible $40;
- line 4: allowed 60 (the last of 340 = 272.
The result's totals are billed 119000, allowed 96000, plan_pays 36200, member_owes 59800, and status ana 2025-06-01 returns member and family deductible 50000, oop 59800.
Part 2 — Late claims, adjustments and reversals (FIN-3402)
Goal: claims reach us out of order and get corrected. A claim's price must depend on *what the member's policy had really used by that service date*, not on the order claims happened to arrive. This replaces the Part 1 rule that claims are charged as they are submitted.
Rules
- The invariant. For every (family, plan, plan year), the price of every *live* line is what you get by pricing all live lines of that policy and plan year in order of (service date, claim arrival number, line number) with the Part 1 rules, starting from zero accumulators. This holds after every operation. Lines of different policies or plan years never affect each other.
- The arrival number is the 1-based order in which claims were successfully submitted. A rejected submit does not use one;
adjustkeeps the claim's arrival number. submitreturns the claim's result under rule 1, *including the effect of claims that arrived earlier but have later service dates*, so previously returned results of other claims may now be stale;claim,totalsandstatusalways show the current truth.["reverse", claim_id]voids a claim. Returns{"claim": id, "status": "reversed", "reprocessed": [ids]}. Errors:invalid arguments(id is not a non-empty string),unknown claim,claim reversed. Afterwardsclaimreturns{"claim": id, "member": member, "status": "reversed"}andtotalsreturns"reversed"; its lines contribute nothing to any accumulator; the id stays used (submitof it isduplicate claim).["adjust", claim_id, lines]replaces the claim's lines (same member, same arrival number, line numbers restart at 1;linesis validated like a claim's). Returns{"claim": id, "status": "adjusted", "reprocessed": [ids]}. Errors in this order:invalid arguments,unknown claim,claim reversed,invalid claim. An erroring call changes nothing.reprocessedlists the *other* claims whose result dict (whatclaimwould return) is different after the operation than before it. Order: the claim's earliest service date, then arrival number. A claim whose result did not change is not listed.- Everything from Part 1 still holds (all of its examples and error orders).
Example. gold, ana; C1 = one 73721 $400 line on 2025-01-20 and C2 = one 73721 $400 line on 2025-03-01, submitted in that order. C2 is deductible 60 = member owes 400 and member_owes 40000: it is the first live line of the year.
Part 3 — Coordination of benefits (FIN-3455)
Goal: some members have a second insurer. A secondary policy picks up part of what the primary left the member owing.
Rules
["cob", member, family, plan_id]givesmembersecondary coverage underplan_idforfamily. Returnstrue. Errors in this order:invalid arguments(an argument is not a non-empty string),unknown member,unknown plan,secondary exists,invalid secondary(plan_idis the member's primary plan). A member has at most one secondary.- Secondary coverage is retroactive: it applies to all of the member's claims, including ones adjudicated before
cobran, and never depends on whencobwas issued. - One accumulator set per policy. A policy is (family id, plan id) and a plan year. It is the same set of accumulators whether it serves someone's primary coverage or someone else's secondary; all lines charged to it, from any member in either role, are priced together in Part 2 order. (So a family id can be shared by members whose primaries are different families.)
- What the secondary sees. Each line the primary did not deny (
status"paid") whose code is in the secondary plan's fee schedule is priced under the secondary with the Part 1 rules, using the secondary's own fee schedule, copays, limits and plan year, as if it were primary ("standalone"). The secondary's accumulators advance by that standalone result *whatever it ends up paying*. A line the primary denied, or that the secondary does not cover, hassecondary_pays0 and charges nothing to the secondary. - Payment. Per line:
secondary_pays = min(standalone plan_pays under the secondary, the primary's member_owes for that line). Never per claim. - Result shape, only for members with a secondary (members without one see exactly the Part 1/2 shape): each line gets
primary_owes(whatmember_oweswas under the primary alone;billedfor denied lines) andsecondary_pays, and the line'smember_owesbecomesprimary_owes - secondary_pays. The claim gets asecondary_paystotal; itsmember_owesis the sum of linemember_owes;plan_paysand the other fields stay the primary's. The third element oftotals/intakeis thatsecondary_pays.statusfor such a member gains a"secondary"entry: the same{plan, plan_year, member, family}block for the secondary policy and the plan year of the date. - Reversals, adjustments and late arrivals affect both the primary and the secondary policy, and
reprocessed(Part 2 rule 6) includes any claim whose combined result changed, including other members' claims that share a secondary family.
Example. plans gold, silver; ana in gold; C1: 73721 $400 (2025-02-01), 99213 $150 (2025-02-02), 00000 $10 (2025-02-03); then ["cob", "ana", "S1", "silver"]. Under gold: line 1 owes 30 copay, line 3 is denied (owes 400 (billed is lower than silver's 200 + coinsurance 30% of 60, so silver's standalone plan pays 20, silver would pay $130, and secondary_pays is min(13000, 3000) = 3000; line 3 is 0. claim C1 then has secondary_pays 17000 and member_owes 27000 (26000 + 0 + 1000).
Part 4 — Year-end volume (FIN-3490)
Goal: December correction runs re-price a whole year of claims and take hours. Make every submit, reverse, adjust and cob cost roughly what it actually changes: not the number of claims in the system, and not even the length of one policy-year.
["storm", spec] (already implemented in claims/storm.py) builds a fresh engine, registers two plans, enrolls families families of members members, submits members * claims_per_member claims per family in service-date order (claims have 1-3 lines, dates inside a ~400-day window starting 2025-01-01, crossing a plan-year boundary), then applies corrections operations in rotation: a reverse, an adjust, and a late-arriving submit, through the same code paths as the public operations. Optional spec keys: window "late" confines corrections to the last twelfth of the window, cob_every gives some members a secondary, oop_family scales the plans' limits. It returns a digest dict: counts of live and corrected claims, the total reprocessed entries, summed member_owes/plan_pays/secondary_pays, and a checksum over all live claim results. The digest must be exactly what a slow, obviously-correct engine produces.
Hidden tests include many families with hundreds of corrections, and a single policy-year with thousands of lines corrected near its end. A solution that re-prices every claim, or even every line of the affected policy-year, for each operation will time out. Most corrections leave most later lines exactly as they were: don't recompute what cannot have changed, and don't make an in-order submit cost more than appending.
Notes
- Everything from earlier Parts must keep working; hidden tests re-check them after each Part.
- Read the existing code for conventions the tables above do not repeat (id formats are free-form strings; dates compare as text; plans live in the catalog).
tests/runs againstrun; add tests for the rules you change.
Example 1
- Input:
- [["plan", {"id":"gold", ...}], ["enroll","ana","F1","gold"], ["submit", {"id":"C1","member":"ana","lines":[{"code":"99213","billed":15000,"date":"2025-02-03"},{"code":"80053","billed":4000,"date":"2025-02-03"},{"code":"73721","billed":60000,"date":"2025-01-20"},{"code":"73721","billed":40000,"date":"2025-03-01"}]}]]
- Output:
- {"claim":"C1","status":"adjudicated","billed":119000,"allowed":96000,"plan_pays":36200,"member_owes":59800, ...}
- Explanation
- Lines are priced in service-date order. The January imaging line consumes $400 of the $500 deductible, the office copay does not count toward it, the lab line takes $40 more, and the March imaging line spends the last $60 of deductible and pays 20% coinsurance on the remaining $340.
Example 2
- Input:
- [... submit C1 (Jan imaging, member ana) ..., submit C2 (Mar imaging, member ana) ..., ["reverse","C1"]]
- Output:
- {"claim":"C1","status":"reversed","reprocessed":["C2"]}
- Explanation
- Reversing the January claim gives back the deductible it used, so the March claim, which already sat behind it in date order, is re-priced and reported.
Guidelines
- Python 3 standard library only
- All money is integer cents; there is no floating-point money anywhere
- Dates are ISO YYYY-MM-DD strings; plan years are named by the calendar year in which they start
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised
- Part 4 inputs contain thousands of claims and hundreds of corrections generated by the storm operation