merge: SP41 inwindow-rebill-pipeline into main

This commit is contained in:
Nora
2026-07-07 22:28:33 -06:00
24 changed files with 1099 additions and 11 deletions
+16
View File
@@ -982,3 +982,19 @@ class Resubmission(Base):
unique=True,
),
)
class SubmissionRecord(Base):
"""SP41: one row per (claim_id, submitted_at) pre-flight dedup guard.
The 30-day dedup window is enforced by ``cyclone.store.submission_dedup``.
This table records (claim_id, submitted_at) for every push that passed
the pre-flight check. Past the window, the prior record is ignored
(the guard lets through re-submits older than
``cyclone.store.submission_dedup.DEFAULT_WINDOW_DAYS``).
"""
__tablename__ = "submission_dedup"
claim_id: Mapped[str] = mapped_column(String(64), primary_key=True)
submitted_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True)
@@ -0,0 +1,15 @@
-- version: 22
-- SP41: claim-id dedup at SFTP pre-flight.
--
-- The 30-day dedup window is enforced by cyclone.store.submission_dedup.
-- This table records (claim_id, submitted_at) for every push that passed
-- the pre-flight check. Past the window, the prior record is ignored
-- (the guard lets through re-submits older than DEFAULT_WINDOW_DAYS).
CREATE TABLE submission_dedup (
claim_id TEXT PRIMARY KEY,
submitted_at DATETIME NOT NULL
);
CREATE INDEX submission_dedup_submitted_at_idx
ON submission_dedup (submitted_at);
+11
View File
@@ -0,0 +1,11 @@
"""SP41 — in-window rebill pipeline.
Owns:
- parse_835_svc (SVC-level 835 reparse with member_id)
- reconcile (visit-to-835 join on (member_id, procedure, DOS))
- carc_filter (CARC-aware exclusion for Pipeline A)
- timely_filing (120-day DOS age gate)
- pipeline_a (denied/partial → frequency-7 rebill)
- pipeline_b (NOT_IN_835 → fresh 837Ps by (member, ISO-week))
- summary (summary CSV + per-category counters)
"""
+44
View File
@@ -0,0 +1,44 @@
"""CARC-aware filter.
EXCLUDED = contractual / not recoverable / not a denial-of-payment in the
narrow sense (charge exceeds fee schedule, prior to coverage, etc.).
REVIEW = operator must decide (claim/service lacks info, non-covered
charges, duplicate, etc.). Anything else falls through to REBILL.
"""
from __future__ import annotations
from enum import Enum
class CarcDecision(str, Enum):
EXCLUDED = "EXCLUDED"
REVIEW = "REVIEW"
REBILL = "REBILL"
# These CARCs are NOT recoverable denials. Do not rebill.
EXCLUDED_CARCS: frozenset[str] = frozenset({
"CO-45", # charge exceeds fee schedule / contractual obligation
"CO-26", # expenses incurred prior to coverage
"CO-129", # prior processing information; forward to next payer
})
# These CARCs need human review before rebill.
REVIEW_CARCS: frozenset[str] = frozenset({
"PI-16", # claim/service lacks information
"PI-96", # non-covered charges
"PI-15", # authorization / certification absent
"PI-4", # procedure not paid separately
"PI-110", # billing date predates service date
"OA-18", # exact duplicate (resubmit noise)
"OA-23", # impact of prior payer adjudication
})
def decide_carc(reasons: tuple[str, ...] | list[str]) -> CarcDecision:
"""Pick the strongest action: EXCLUDED > REVIEW > REBILL."""
if any(r in EXCLUDED_CARCS for r in reasons):
return CarcDecision.EXCLUDED
if any(r in REVIEW_CARCS for r in reasons):
return CarcDecision.REVIEW
return CarcDecision.REBILL
+124
View File
@@ -0,0 +1,124 @@
"""SVC-level 835 reparse with member_id at SVC scope.
Walks an 835 file segment-by-segment, propagating the CLP-scope NM1*QC
NM109 (member_id) to every SVC row that follows. Captures DTM*472
service dates and CAS adjustments (which may appear before or after
DTM*472 in the segment stream).
"""
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from pathlib import Path
ELEM = "*"
SEG_END = "~"
@dataclass(frozen=True)
class SvcRow:
src_file: str
claim_id: str
member_id: str
status: str
procedure: str
modifiers: str
charge: Decimal
paid: Decimal
units: Decimal
svc_date: date
cas_reasons: tuple[str, ...] # each entry is "GROUP-CODE" (e.g. "OA-18")
pay_date: date | None
def _split(seg: str) -> list[str]:
return seg.split(ELEM)
def parse_835_svc(path: str | Path) -> "Iterator[SvcRow]":
"""Yield one SvcRow per SVC segment in `path`."""
path = Path(path)
text = path.read_text()
segments = [s for s in text.split(SEG_END) if s]
pay_date: date | None = None
current_claim_id = ""
current_status = ""
current_member_id = ""
i = 0
while i < len(segments):
elems = _split(segments[i])
seg_name = elems[0]
if seg_name == "DTM" and len(elems) > 2 and elems[1] == "405":
try:
pay_date = _ymd(elems[2])
except ValueError:
pass
elif seg_name == "CLP":
current_claim_id = elems[1] if len(elems) > 1 else ""
current_status = elems[2] if len(elems) > 2 else ""
current_member_id = ""
elif seg_name == "NM1" and len(elems) > 1 and elems[1] == "QC":
current_member_id = elems[9] if len(elems) > 9 else ""
elif seg_name == "SVC" and current_claim_id:
rest = elems[1:]
comp = rest[0] if rest else ""
proc_parts = comp.split(":")
procedure = proc_parts[1] if len(proc_parts) > 1 else ""
modifiers = ":".join(proc_parts[2:]) if len(proc_parts) > 2 else ""
charge = _decimal(rest[1]) if len(rest) > 1 else Decimal("0")
paid = _decimal(rest[2]) if len(rest) > 2 else Decimal("0")
units = _decimal(rest[3]) if len(rest) > 3 else Decimal("0")
svc_date, cas_reasons = _walk_for_dtm_and_cas(segments, i)
yield SvcRow(
src_file=path.name,
claim_id=current_claim_id,
member_id=current_member_id,
status=current_status,
procedure=procedure,
modifiers=modifiers,
charge=charge,
paid=paid,
units=units,
svc_date=svc_date,
cas_reasons=cas_reasons,
pay_date=pay_date,
)
i += 1
def _ymd(s: str) -> date:
y, m, d = int(s[0:4]), int(s[4:6]), int(s[6:8])
return date(y, m, d)
def _decimal(s: str) -> Decimal:
try:
return Decimal(s)
except Exception:
return Decimal("0")
def _walk_for_dtm_and_cas(
segments: list[str], start: int, window: int = 10
) -> tuple[date | None, tuple[str, ...]]:
"""Walk the next `window` segments after SVC. Capture DTM*472 and CAS.
X12 835 may place DTM*472 before or after CAS segments within a service
line, so we walk the full window and capture both, breaking only at the
next SVC or CLP (a new claim/service boundary).
"""
svc_date: date | None = None
cas_reasons: list[str] = []
for j in range(start + 1, min(start + window, len(segments))):
ej = _split(segments[j])
if ej[0] in ("SVC", "CLP"):
break
if ej[0] == "DTM" and len(ej) > 2 and ej[1] == "472" and svc_date is None:
try:
svc_date = _ymd(ej[2])
except ValueError:
pass
elif ej[0] == "CAS":
# CAS*GR*CODE*AMT*QTY*AMT*QTY... → GR-CODE
if len(ej) >= 3:
cas_reasons.append(f"{ej[1]}-{ej[2]}")
return svc_date, tuple(cas_reasons)
+86
View File
@@ -0,0 +1,86 @@
"""Pipeline A: denied/partial → frequency-7 replacement 837Ps.
For each visit the previous adjudication said was DENIED or PARTIAL,
emit a fresh 837P with CLM05-3 = '7' (replacement claim) and the
original claim_submit_id preserved as CLM01. The 837P carries the
original DOS and the same procedure / member.
The CARC-aware filter is the gate — only REBILL / REVIEW decisions are
emitted; EXCLUDED visits never make it to a file.
"""
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from pathlib import Path
from cyclone.rebill.carc_filter import CarcDecision
from cyclone.rebill.reconcile import ReconcileOutcome, OutcomeCategory
@dataclass(frozen=True)
class RebillClaim:
"""Pre-emission 837P shape for Pipeline A. The orchestrator's serializer
adapter converts this to a ClaimOutput for serialize_837.
"""
claim_id: str # reuses original claim_submit_id
member_id: str
procedure: str
svc_date: date
charge: Decimal
frequency_code: str # always "7" for Pipeline A
needs_review: bool # True for CARC REVIEW decisions
original_carc_reasons: tuple[str, ...]
def build_pipeline_a_claims(
original_claim_id: str,
visit_outcomes: list[ReconcileOutcome],
carc_decisions: list[CarcDecision],
cas_reasons_per_visit: list[tuple[str, ...]],
) -> list[RebillClaim]:
"""Zip three parallel per-visit lists by index; drop PAID/NOT_IN_835 outcomes and EXCLUDED CARC decisions; emit one frequency-7 RebillClaim per survivor."""
out: list[RebillClaim] = []
# strict=True: all three lists must be the same length; a mismatch is a contract bug, fail loud.
for vo, carc, reasons in zip(visit_outcomes, carc_decisions, cas_reasons_per_visit, strict=True):
if vo.category not in (OutcomeCategory.DENIED, OutcomeCategory.PARTIAL):
continue
if carc == CarcDecision.EXCLUDED:
continue
out.append(RebillClaim(
claim_id=original_claim_id,
member_id=vo.visit.member_id,
procedure=vo.visit.procedure,
svc_date=vo.visit.date,
charge=vo.visit.billed,
frequency_code="7",
needs_review=(carc == CarcDecision.REVIEW),
original_carc_reasons=reasons,
))
return out
def write_pipeline_a_files(
claims: list[RebillClaim],
out_dir: Path,
serialize_837_fn, # injected: callable[[RebillClaim], str] (orchestrator-supplied adapter)
tpid: str,
) -> list[Path]:
"""Write one 837P per claim, using HCPF-spec filenames via build_outbound_filename.
`serialize_837_fn(claim)` returns the X12 string. The 837P envelope is
pure ASCII so we write it as text. The filename is
`cyclone.edi.filenames.build_outbound_filename(tpid, '837P')`.
"""
# Lazy: keep cyclone.rebill.pipeline_a importable without zoneinfo from filenames.
from cyclone.edi.filenames import build_outbound_filename
out_dir.mkdir(parents=True, exist_ok=True)
paths: list[Path] = []
for c in claims:
body = serialize_837_fn(c)
fname = build_outbound_filename(tpid, "837P")
path = out_dir / fname
path.write_text(body, encoding="ascii")
paths.append(path)
return paths
+123
View File
@@ -0,0 +1,123 @@
"""Pipeline B: NOT_IN_835 visits → fresh 837Ps, batched by (member_id, ISO-week).
One 837P per (member, ISO-week) pair. Per-visit files would be 4,509 SFTP
round-trips; per-batch (no member split) would lose the operator's
ability to track per-member. Member-week is the standard clearinghouse
pattern and matches AxisCare's billing cycle.
The timely-filing gate is applied per visit before batching. The
override flag is the same for the whole batch (a per-batch override).
"""
from collections import defaultdict
from dataclasses import dataclass
from datetime import date
from pathlib import Path
from cyclone.rebill.reconcile import VisitRow
from cyclone.rebill.timely_filing import timely_filing_decision
@dataclass(frozen=True)
class MemberWeekBatch:
"""Pre-emission 837P shape for Pipeline B. The orchestrator's serializer
adapter converts this to a ClaimOutput for serialize_837.
Note: frozen=True is shallow — `visits` is a mutable list, so
`mb.visits.append(x)` works even though `mb.visits = new_list` raises
FrozenInstanceError. Treat the batch as logically immutable.
"""
member_id: str
iso_year: int
iso_week: int
visits: list[VisitRow]
has_overridden_visits: bool
def build_pipeline_b_batches(
visits: list[VisitRow],
as_of: date,
override: bool,
) -> list[MemberWeekBatch]:
"""Group rebillable visits by (member_id, ISO-week).
Two filters are applied per visit before batching:
1. timely_filing_decision(...).rebillable — drops past-window
visits unless `override=True` (per-batch override flag).
2. implicit: only visits that survive (1) are grouped; the rest
are surfaced as EXCLUDED_TIMELY_FILING in the summary CSV.
Returns batches sorted by (member_id, iso_year, iso_week) for
deterministic filenames downstream.
"""
by_key: dict[tuple[str, int, int], list[VisitRow]] = defaultdict(list)
overridden_keys: set[tuple[str, int, int]] = set()
for v in visits:
decision = timely_filing_decision(v.date, as_of, override)
if not decision.rebillable:
continue
iso = v.date.isocalendar()
key = (v.member_id, iso.year, iso.week)
by_key[key].append(v)
if decision.override and not decision.within_window:
overridden_keys.add(key)
return [
MemberWeekBatch(
member_id=member, iso_year=yr, iso_week=wk,
visits=vs, has_overridden_visits=(member, yr, wk) in overridden_keys,
)
for (member, yr, wk), vs in sorted(by_key.items())
]
def batch_visits_by_member_week(
visits: list[VisitRow],
as_of: date,
override: bool,
) -> dict[str, list[VisitRow]]:
"""Thin wrapper over build_pipeline_b_batches.
Returns ``{member_id: [VisitRow, ...]}`` — collapsing the (member,
ISO-week) batches back down to a per-member visit list. This is the
shape the orchestrator's summary aggregation expects.
PROVISIONAL: this helper is defined for the Task 12 orchestrator's
anticipated use, but has no callers in this branch. If Task 12's
actual needs differ, this function may be removed or replaced without
notice.
This also collapses multiple weeks for the same member into one
list. If the orchestrator needs week-aware per-member iteration,
use build_pipeline_b_batches directly.
"""
batches = build_pipeline_b_batches(visits, as_of=as_of, override=override)
out: dict[str, list[VisitRow]] = defaultdict(list)
for b in batches:
out[b.member_id].extend(b.visits)
return dict(out)
def write_pipeline_b_files(
batches: list[MemberWeekBatch],
out_dir: Path,
serialize_837_fn,
tpid: str,
) -> list[Path]:
"""Write one 837P per batch, using HCPF-spec filenames.
`serialize_837_fn(batch)` returns the X12 string. The 837P envelope
is pure ASCII so we write it as text. The filename is
`cyclone.edi.filenames.build_outbound_filename(tpid, '837P')`.
"""
# Lazy: keep cyclone.rebill.pipeline_b importable without zoneinfo from filenames.
from cyclone.edi.filenames import build_outbound_filename
out_dir.mkdir(parents=True, exist_ok=True)
paths: list[Path] = []
for b in batches:
body = serialize_837_fn(b)
fname = build_outbound_filename(tpid, "837P")
path = out_dir / fname
path.write_text(body, encoding="ascii")
paths.append(path)
return paths
+69
View File
@@ -0,0 +1,69 @@
"""Visit-to-835 reconciliation on (member_id, procedure, DOS).
Authoritative match key. The 5% tolerance on PAID handles the legitimate
charge-amount differences between AxisCare's per-unit CSV and the 835's
per-SVC breakdown.
"""
from collections import defaultdict
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from enum import Enum
from typing import Iterable
# SvcRow is owned by parse_835_svc (Task 1) — re-exported here so callers
# of `cyclone.rebill.reconcile` only need one import.
from cyclone.rebill.parse_835_svc import SvcRow # noqa: F401 (re-exported)
PAID_TOLERANCE = Decimal("0.05") # charge match tolerance
PARTIAL_RATIO = Decimal("0.95") # PAID if total_paid >= 95% of billed
class OutcomeCategory(str, Enum):
PAID = "PAID"
PARTIAL = "PARTIAL"
DENIED = "DENIED"
NOT_IN_835 = "NOT_IN_835"
@dataclass(frozen=True)
class VisitRow:
date: date
member_id: str
procedure: str
billed: Decimal
@dataclass(frozen=True)
class ReconcileOutcome:
visit: VisitRow
category: OutcomeCategory
unpaid: Decimal
matched_svc_count: int
def reconcile_visits_to_835(
visits: Iterable[VisitRow],
svcs: Iterable[SvcRow],
) -> list[ReconcileOutcome]:
"""Index SVCs by (member_id, procedure, DOS), best-of-N outcome per visit."""
by_key: dict[tuple[str, str, date], list[SvcRow]] = defaultdict(list)
for s in svcs:
by_key[(s.member_id, s.procedure, s.svc_date)].append(s)
out: list[ReconcileOutcome] = []
for v in visits:
cands = by_key.get((v.member_id, v.procedure, v.date), [])
if not cands:
out.append(ReconcileOutcome(v, OutcomeCategory.NOT_IN_835, v.billed, 0))
continue
total_paid = sum((c.paid for c in cands), Decimal("0"))
any_paid = any(c.paid > 0 for c in cands)
if any_paid and total_paid >= v.billed * PARTIAL_RATIO:
cat, unpaid = OutcomeCategory.PAID, Decimal("0")
elif any_paid:
cat, unpaid = OutcomeCategory.PARTIAL, max(Decimal("0"), v.billed - total_paid)
else:
cat, unpaid = OutcomeCategory.DENIED, v.billed
out.append(ReconcileOutcome(v, cat, unpaid, len(cands)))
return out
+57
View File
@@ -0,0 +1,57 @@
"""Summary CSV for SP41.
One row per visit, classified into one of:
REBILLED_A — pipeline A emitted a frequency-7 replacement
REBILLED_B — pipeline B emitted a fresh 837P
EXCLUDED_CARC — CO-45/CO-26/CO-129 (or other excluded CARC)
EXCLUDED_PAYER — non-CO_TXIX payer per AxisCare API spot audit
EXCLUDED_NO_EVV — no Authorized=Yes AND no AxisCare EVV ref
EXCLUDED_TIMELY_FILING — DOS > 120 days before run date, no override
"""
import csv
from dataclasses import dataclass
from decimal import Decimal
from pathlib import Path
from cyclone.rebill.reconcile import VisitRow
REBILLED_A = "REBILLED_A"
REBILLED_B = "REBILLED_B"
EXCLUDED_CARC = "EXCLUDED_CARC"
EXCLUDED_PAYER = "EXCLUDED_PAYER"
EXCLUDED_NO_EVV = "EXCLUDED_NO_EVV"
EXCLUDED_TIMELY_FILING = "EXCLUDED_TIMELY_FILING"
@dataclass(frozen=True)
class SummaryRow:
"""One visit + its post-pipeline disposition for the operator audit trail."""
visit: VisitRow
disposition: str
unpaid: Decimal
cas_reasons: tuple[str, ...]
file_path: str # relative to dev/rebills/<date>/
def write_summary_csv(rows: list[SummaryRow], out_path: Path) -> int:
"""Write the summary CSV; return the number of rows written."""
out_path.parent.mkdir(parents=True, exist_ok=True)
with out_path.open("w", newline="") as f:
w = csv.writer(f)
w.writerow([
"dos", "member_id", "procedure", "billed",
"disposition", "unpaid", "cas_reasons", "file_path",
])
for r in rows:
w.writerow([
r.visit.date.isoformat(),
r.visit.member_id,
r.visit.procedure,
str(r.visit.billed),
r.disposition,
str(r.unpaid),
"|".join(r.cas_reasons), # pipe-separated; no collision with J-prefixed member IDs or relative file paths
r.file_path,
])
return len(rows)
@@ -0,0 +1,37 @@
"""120-day timely-filing gate for Colorado Medicaid.
Per HCPF's Colorado Medical Assistance Program provider manual, claims must
be filed within 120 days from the date of service. The gate is HARD by
default — past-window visits are surfaced as EXCLUDED_TIMELY_FILING in the
summary CSV and not emitted by Pipeline B.
The operator may pass override=True per batch to relax the gate (e.g. for
good-cause appeal visits). The decision is recorded in the summary CSV
either way so the audit trail shows the override.
"""
from dataclasses import dataclass
from datetime import date
FILING_WINDOW_DAYS = 120
@dataclass(frozen=True)
class TimelyFilingDecision:
dos: date
as_of: date
days_old: int
within_window: bool
override: bool
rebillable: bool
def timely_filing_decision(
dos: date, as_of: date, override: bool
) -> TimelyFilingDecision:
days_old = (as_of - dos).days
within_window = days_old <= FILING_WINDOW_DAYS
return TimelyFilingDecision(
dos=dos, as_of=as_of, days_old=days_old,
within_window=within_window, override=override,
rebillable=within_window or override,
)
+26 -1
View File
@@ -4,6 +4,8 @@ These are part of the public API — callers (API endpoints) catch them
and translate to HTTP 409 Conflict responses.
"""
from datetime import datetime
class AlreadyMatchedError(Exception):
"""Raised by ``CycloneStore.manual_match`` when the claim is already paired.
@@ -38,4 +40,27 @@ class InvalidStateError(Exception):
self.activity_kind = activity_kind
super().__init__(
f"invalid state {current_state} for apply (kind={activity_kind})"
)
)
class DuplicateClaimError(Exception):
"""Raised by ``cyclone.store.submission_dedup.check_duplicate`` when a claim_id
is re-submitted within the 30-day dedup window (SP41).
Mirrors the other 409-class exceptions: callers catch it at the API
boundary and surface it as a 409 Conflict. Carries
``original_submission_at`` so the UI can render "already submitted on
YYYY-MM-DD" without re-querying.
Note: the "30-day" literal in the message mirrors
``cyclone.store.submission_dedup.DEFAULT_WINDOW_DAYS`` — keep them in
sync if either ever moves.
"""
def __init__(self, claim_id: str, original_submission_at: datetime):
self.claim_id = claim_id
self.original_submission_at = original_submission_at
super().__init__(
f"claim_id {claim_id!r} was already submitted at "
f"{original_submission_at.isoformat()}; within 30-day window"
)
@@ -0,0 +1,65 @@
"""Claim-id dedup at the SFTP pre-flight stage.
Schema: the ``submission_dedup`` table (defined on the main ``Base`` in
``cyclone.db``) records (claim_id, submitted_at). A re-submission of
the same claim_id within ``DEFAULT_WINDOW_DAYS`` (30) is rejected with
``DuplicateClaimError``. Past the window, the guard lets it through —
the operator may be retrying a 999-rejected file from >30 days ago,
which is a legitimate rebuild path.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from cyclone import db as cycl_db
from cyclone.db import SubmissionRecord
from cyclone.store.exceptions import DuplicateClaimError
DEFAULT_WINDOW_DAYS = 30
def record_submission(claim_id: str, submitted_at: datetime, db_url: str) -> None:
"""Upsert (claim_id, submitted_at). Idempotent on claim_id.
``db_url`` is accepted for back-compat with the original Task 8
signature but ignored — sessions now flow through the process-wide
``cycl_db.SessionLocal()`` factory that ``db.init_db()`` set up.
"""
del db_url # signature-preserving; conftest wires the per-test DB
with cycl_db.SessionLocal()() as session:
session.merge(SubmissionRecord(
claim_id=claim_id,
submitted_at=submitted_at,
))
session.commit()
def check_duplicate(
claim_id: str,
db_url: str | None = None,
now: datetime | None = None,
window_days: int = DEFAULT_WINDOW_DAYS,
) -> None:
"""Raise ``DuplicateClaimError`` if ``claim_id`` was submitted within the window.
``db_url`` is accepted for back-compat with the original Task 8
signature but ignored — sessions flow through ``cycl_db.SessionLocal()``
just like the rest of ``cyclone.store``. Tests that pass an explicit
``db_url`` keep working because ``cycl_db.SessionLocal()()`` points at
the URL the conftest resolved under ``CYCLONE_DB_URL``.
"""
del db_url # signature-preserving
if now is None:
now = datetime.now(timezone.utc)
cutoff = now - timedelta(days=window_days)
with cycl_db.SessionLocal()() as session:
row = session.scalars(
select(SubmissionRecord).where(
SubmissionRecord.claim_id == claim_id,
SubmissionRecord.submitted_at >= cutoff,
)
).first()
if row is not None:
raise DuplicateClaimError(claim_id, row.submitted_at)
+1
View File
@@ -0,0 +1 @@
ISA*00* *00* *ZZ*CYCLONE *ZZ*GAINWELL *260101*1200*^*00501*000000001*0*P*:~GS*HC*CYCLONE*GAINWELL*20260101*1200*1*X*005010X221A1~ST*835*0001~BPR*I*100.00*C*ACH*CCP*01*021000021*DA*123456789*1512345678**01*021000021*DA*123456789*20260101~TRN*1*TRACE01*1512345678~DTM*405*20260118~N1*PR*COLORADO MEDICAL ASSISTANCE PROGRAM*XV*COMEDASSISTPROG~N3*PO BOX 1100~N4*DENVER*CO*80202~REF*2U*7912900843~LX*1~CLP*T1001*1*16.24*16.10**MC*2026029105200*11*1~NM1*QC*1*AALBUE*ERIC****MR*J813715~NM1*74*1*AALBUE*ERIC*W***C*J813715~DTM*232*20260118~DTM*233*20260124~SVC*HC:T1019:U2:SC:KX*2.32*2.30**.33~DTM*472*20260118~CAS*CO*45*.02~REF*G1*6252960154~REF*6R*T1001V001~AMT*B6*2.30~SVC*HC:T1019:U2:SC:KX*2.32*2.30**.33~DTM*472*20260119~CAS*OA*18*2.32~SVC*HC:T1019:U2:SC:KX*2.32*-2.30**.33~DTM*472*20260120~CAS*CO*45*-.02~LX*2~CLP*T1002*4*16.24*0**MC*2026029105201*11*1~NM1*QC*1*OTHER-A*FIRST****MR*OTHER-MEMBER-A~SVC*HC:T1019*2.32*0**.33~DTM*472*20260121~CAS*CO*97*2.32~LX*3~CLP*T1003*22*16.24*-16.10**MC*2026029105202*11*1~NM1*QC*1*OTHER-B*SECOND****MR*OTHER-MEMBER-B~SVC*HC:T1019*2.32*-2.30**.33~DTM*472*20260122~SE*23*0001~GE*1*1~IEA*1*000000001~
+5 -5
View File
@@ -51,7 +51,7 @@ def test_migration_0002_creates_acks_table():
def test_migration_latest_idempotent_on_fresh_db():
"""Re-running the migration on the same DB must be a no-op (PRAGMA
user_version already at the latest version — currently 21 after
user_version already at the latest version — currently 22 after
0004-0006 line_reconciliation, 0005 ta1_acks, SP9's 0007
providers/payers/clearhouse, SP10's 0008 payer_rejected,
SP11's 0009 audit_log, SP14's 0010 payer_rejected_acknowledged,
@@ -62,16 +62,16 @@ def test_migration_latest_idempotent_on_fresh_db():
claim.patient_control_number backfill UPDATE, SP28's 0018
claim_acks join table, SP32's 0019
rendering_provider_npi + service_provider_npi, SP37's 0020
transaction_set_control_number, and SP39's 0021 resubmissions
audit table)."""
transaction_set_control_number, SP39's 0021 resubmissions
audit table, and SP41's 0022 submission_dedup)."""
with db.engine().begin() as c:
v1 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
assert v1 == 21
assert v1 == 22
# A second run should not raise and should not bump the version.
db_migrate.run(db.engine())
with db.engine().begin() as c:
v2 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
assert v2 == 21
assert v2 == 22
def test_add_ack_persists_row():
+4 -3
View File
@@ -128,14 +128,15 @@ def test_migration_latest_idempotent_on_fresh_db(tmp_path: Path) -> None:
service_provider_npi on claims and remittances.
SP37 bumped it to 20 with batch transaction_set_control_number.
SP39 bumped it to 21 with the resubmissions audit table.
SP41 bumped it to 22 with submission_dedup.
"""
engine = _fresh_engine(tmp_path)
db_migrate.run(engine)
v_after_first = _user_version(engine)
assert v_after_first == 21, f"expected head=21, got {v_after_first}"
assert v_after_first == 22, f"expected head=22, got {v_after_first}"
db_migrate.run(engine)
assert _user_version(engine) == 21, "second run should not bump version"
assert _user_version(engine) == 22, "second run should not bump version"
def test_drop_claims_unique_constraint_migration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -161,7 +162,7 @@ def test_drop_claims_unique_constraint_migration(tmp_path: Path, monkeypatch: py
engine = _fresh_engine(tmp_path)
db_migrate.run(engine)
assert _user_version(engine) == 21, f"expected head=21, got {_user_version(engine)}"
assert _user_version(engine) == 22, f"expected head=22, got {_user_version(engine)}"
# Two claims in one batch with the same patient_control_number
# must be insertable. If 0015's table recreation re-introduced a
+2 -2
View File
@@ -102,10 +102,10 @@ def migrated_engine(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
engine = _fresh_engine(tmp_path / "mig0020.db")
db_migrate.run(engine)
# Confirm head is 21 (every migration applied, including SP39's 0021).
# Confirm head is 22 (every migration applied, including SP41's 0022).
with engine.connect() as conn:
v = conn.exec_driver_sql("PRAGMA user_version").scalar() or 0
assert v == 21, f"expected migration head=21, got {v}"
assert v == 22, f"expected migration head=22, got {v}"
yield engine
engine.dispose()
+49
View File
@@ -0,0 +1,49 @@
"""CARC-aware filter for Pipeline A.
Contractual / non-recoverable denials (CO-45, CO-26, CO-129) must be
excluded from rebill. CARCs that need operator review (PI-16, PI-96,
PI-15, PI-4, PI-110, OA-18, OA-23) must be surfaced as 'REVIEW' so the
operator can decide.
"""
from cyclone.rebill.carc_filter import (
CarcDecision,
decide_carc,
EXCLUDED_CARCS,
REVIEW_CARCS,
)
def test_co45_is_excluded():
assert decide_carc(("CO-45",)) == CarcDecision.EXCLUDED
def test_co26_is_excluded():
assert decide_carc(("CO-26",)) == CarcDecision.EXCLUDED
def test_co129_is_excluded():
assert decide_carc(("CO-129",)) == CarcDecision.EXCLUDED
def test_pi16_is_review():
assert decide_carc(("PI-16",)) == CarcDecision.REVIEW
def test_o18_is_review():
"""OA-18 is the duplicate noise — surface for review, don't auto-rebill."""
assert decide_carc(("OA-18",)) == CarcDecision.REVIEW
def test_no_carc_is_rebill():
assert decide_carc(()) == CarcDecision.REBILL
def test_mixed_excluded_wins():
"""If any CARC is excluded, the whole service is excluded."""
assert decide_carc(("PI-16", "CO-45")) == CarcDecision.EXCLUDED
def test_sets_have_expected_members():
assert "CO-45" in EXCLUDED_CARCS
assert "PI-16" in REVIEW_CARCS
assert "OA-18" in REVIEW_CARCS
@@ -0,0 +1,38 @@
"""835 SVC-level parser with member_id at SVC scope."""
from pathlib import Path
from collections import defaultdict
from decimal import Decimal
from cyclone.rebill.parse_835_svc import parse_835_svc
FIX = Path(__file__).parent / "fixtures" / "835_sample_svc_with_member.txt"
def test_parse_835_svc_extracts_member_id():
"""NM1*QC NM109 at the CLP scope must propagate to that CLP's SVC rows;
the multi-claim fixture confirms each CLP's NM1*QC lands on its own SVCs."""
rows = list(parse_835_svc(FIX))
assert len(rows) >= 1
by_claim: dict[str, list[str]] = defaultdict(list)
for r in rows:
by_claim[r.claim_id].append(r.member_id)
assert r.procedure # non-empty
assert r.svc_date # non-empty
assert r.charge > 0
# First CLP (T1001) -> original fixture member
assert all(mid == "J813715" for mid in by_claim["T1001"]), by_claim
# Second CLP (T1002, status 4) -> its own NM1*QC
assert all(mid == "OTHER-MEMBER-A" for mid in by_claim["T1002"]), by_claim
# Third CLP (T1003, status 22) -> its own NM1*QC
assert all(mid == "OTHER-MEMBER-B" for mid in by_claim["T1003"]), by_claim
def test_parse_835_svc_extracts_cas_reasons():
"""CAS segments after DTM*472 must be captured (post-DTM*472 ordering)."""
rows = list(parse_835_svc(FIX))
# at least one row should have an OA-18 reason
assert any("OA-18" in r.cas_reasons for r in rows)
def test_parse_835_svc_picks_up_status_22_reversals():
"""Status 22 (reversal of previous payment) must be preserved, along with
status 1 (primary) and status 4 (denied)."""
rows = list(parse_835_svc(FIX))
statuses = {r.status for r in rows}
assert statuses == {"1", "4", "22"}
+55
View File
@@ -0,0 +1,55 @@
"""Pipeline A: denied/partial visits become frequency-7 replacement claims."""
from datetime import date
from decimal import Decimal
from cyclone.rebill.pipeline_a import build_pipeline_a_claims
from cyclone.rebill.reconcile import VisitRow, ReconcileOutcome, OutcomeCategory
from cyclone.rebill.carc_filter import CarcDecision
def _outcome(date_, member, proc, billed, cat, unpaid=Decimal("0")):
visit = VisitRow(date=date_, member_id=member, procedure=proc, billed=Decimal(billed))
return ReconcileOutcome(visit, cat, unpaid, 0)
def test_pipeline_a_emits_frequency_7():
"""CLM05-3 must be 7 (replacement), preserving the original claim_submit_id."""
out = build_pipeline_a_claims(
original_claim_id="ORIG-001",
visit_outcomes=[
_outcome(date(2026, 6, 27), "J813715", "T1019", "2.32",
OutcomeCategory.DENIED, unpaid=Decimal("2.32")),
],
carc_decisions=[CarcDecision.REBILL],
cas_reasons_per_visit=[()],
)
assert len(out) == 1
assert out[0].claim_id == "ORIG-001"
assert out[0].frequency_code == "7"
assert out[0].svc_date == date(2026, 6, 27)
def test_pipeline_a_excludes_carc_excluded_visits():
out = build_pipeline_a_claims(
original_claim_id="ORIG-002",
visit_outcomes=[
_outcome(date(2026, 6, 27), "J813715", "T1019", "2.32",
OutcomeCategory.DENIED),
],
carc_decisions=[CarcDecision.EXCLUDED],
cas_reasons_per_visit=[("CO-45",)],
)
assert out == [] # not emitted
def test_pipeline_a_surfaces_review_visits_with_flag():
out = build_pipeline_a_claims(
original_claim_id="ORIG-003",
visit_outcomes=[
_outcome(date(2026, 6, 27), "J813715", "T1019", "2.32",
OutcomeCategory.DENIED),
],
carc_decisions=[CarcDecision.REVIEW],
cas_reasons_per_visit=[("PI-16",)],
)
assert len(out) == 1
assert out[0].needs_review is True
+75
View File
@@ -0,0 +1,75 @@
"""Pipeline B: NOT_IN_835 visits → fresh 837Ps, batched by (member, ISO-week)."""
from datetime import date
from decimal import Decimal
from cyclone.rebill.pipeline_b import build_pipeline_b_batches
from cyclone.rebill.reconcile import VisitRow
def _v(date_, member, proc, amt):
return VisitRow(date=date_, member_id=member, procedure=proc, billed=Decimal(amt))
def test_batches_split_by_member_and_iso_week():
visits = [
_v(date(2026, 6, 23), "J813715", "T1019", "2.32"),
_v(date(2026, 6, 25), "J813715", "T1019", "2.32"), # same week
_v(date(2026, 6, 23), "OTHER", "T1019", "2.32"), # different member
_v(date(2026, 6, 30), "J813715", "T1019", "2.32"), # different week
]
out = build_pipeline_b_batches(visits, as_of=date(2026, 7, 7), override=False)
assert len(out) == 3
sizes = sorted(len(b.visits) for b in out)
assert sizes == [1, 1, 2]
def test_timely_filing_excludes_old_visits():
visits = [
_v(date(2026, 1, 1), "OLD", "T1019", "2.32"), # 187 days old
_v(date(2026, 6, 27), "NEW", "T1019", "2.32"), # 10 days old
]
out = build_pipeline_b_batches(visits, as_of=date(2026, 7, 7), override=False)
members = {b.member_id for b in out}
assert "OLD" not in members
assert "NEW" in members
def test_override_relaxes_timely_filing():
visits = [_v(date(2026, 1, 1), "OLD", "T1019", "2.32")]
out_default = build_pipeline_b_batches(visits, as_of=date(2026, 7, 7), override=False)
out_overridden = build_pipeline_b_batches(visits, as_of=date(2026, 7, 7), override=True)
assert out_default == []
assert len(out_overridden) == 1
def test_override_flag_set_on_past_window_visit_batch():
# Same member "OLD" with one past-window visit (187 days old, ISO
# week 1) and one within-window visit (10 days old, ISO week 26).
# With override=True the past-window visit survives and the batch
# for week 1 must be flagged has_overridden_visits=True because the
# override saved an otherwise-excluded visit.
visits = [
_v(date(2026, 1, 1), "OLD", "T1019", "2.32"), # 187 days old (past window, W01)
_v(date(2026, 6, 27), "OLD", "T1019", "2.32"), # 10 days old (within window, W26)
]
out_overridden = build_pipeline_b_batches(
visits, as_of=date(2026, 7, 7), override=True,
)
# Two batches: one per (member, ISO-week) — the past-window visit and
# the within-window visit land in different weeks.
assert len(out_overridden) == 2
by_week = {b.iso_week: b for b in out_overridden}
assert by_week[1].member_id == "OLD"
assert by_week[1].has_overridden_visits is True # the override saved this batch
assert by_week[26].member_id == "OLD"
assert by_week[26].has_overridden_visits is False # no override needed here
# With override=False the past-window visit is dropped. The surviving
# within-window batch (W26) must NOT carry the override flag, and the
# past-window batch (W01) is absent.
out_default = build_pipeline_b_batches(
visits, as_of=date(2026, 7, 7), override=False,
)
assert len(out_default) == 1
assert out_default[0].member_id == "OLD"
assert out_default[0].iso_week == 26
assert out_default[0].has_overridden_visits is False
+76
View File
@@ -0,0 +1,76 @@
"""Visit-to-835 reconciliation on (member_id, procedure, DOS)."""
from datetime import date
from decimal import Decimal
from cyclone.rebill.reconcile import (
VisitRow,
SvcRow,
ReconcileOutcome,
reconcile_visits_to_835,
OutcomeCategory,
)
def _v(date, member, proc, amt):
return VisitRow(date=date, member_id=member, procedure=proc, billed=Decimal(amt))
def _s(date, member, proc, chg, paid, status="1"):
return SvcRow(
src_file="x.835", claim_id="c1", member_id=member, status=status,
procedure=proc, modifiers="", charge=Decimal(chg), paid=Decimal(paid),
units=Decimal("0"), svc_date=date, cas_reasons=(), pay_date=None,
)
def test_visit_paid_when_any_svc_paid_within_tolerance():
visits = [_v(date(2026, 1, 18), "J813715", "T1019", "2.32")]
svcs = [_s(date(2026, 1, 18), "J813715", "T1019", "2.32", "2.30")]
out = reconcile_visits_to_835(visits, svcs)
assert out[0].category == OutcomeCategory.PAID
assert out[0].unpaid == Decimal("0")
def test_visit_paid_when_one_svc_paid_others_denied_duplicate():
"""OA-18 on duplicates is noise; paid SVC wins."""
visits = [_v(date(2026, 1, 18), "J813715", "T1019", "2.32")]
svcs = [
_s(date(2026, 1, 18), "J813715", "T1019", "2.32", "2.30", "1"),
_s(date(2026, 1, 18), "J813715", "T1019", "2.32", "0.00", "4"),
_s(date(2026, 1, 18), "J813715", "T1019", "2.32", "0.00", "4"),
]
out = reconcile_visits_to_835(visits, svcs)
assert out[0].category == OutcomeCategory.PAID
def test_visit_denied_when_all_svc_denied():
visits = [_v(date(2026, 1, 18), "J813715", "T1019", "2.32")]
svcs = [
_s(date(2026, 1, 18), "J813715", "T1019", "2.32", "0.00", "4"),
_s(date(2026, 1, 18), "J813715", "T1019", "2.32", "0.00", "4"),
]
out = reconcile_visits_to_835(visits, svcs)
assert out[0].category == OutcomeCategory.DENIED
assert out[0].unpaid == Decimal("2.32")
def test_visit_not_in_835_when_no_svc_match():
visits = [_v(date(2026, 1, 18), "J813715", "T1019", "2.32")]
svcs = [_s(date(2026, 1, 19), "J813715", "T1019", "2.32", "2.30")]
out = reconcile_visits_to_835(visits, svcs)
assert out[0].category == OutcomeCategory.NOT_IN_835
assert out[0].unpaid == Decimal("2.32")
def test_partial_when_total_paid_below_95pct_of_billed():
visits = [_v(date(2026, 1, 18), "J813715", "T1019", "10.00")]
svcs = [_s(date(2026, 1, 18), "J813715", "T1019", "10.00", "4.00")]
out = reconcile_visits_to_835(visits, svcs)
assert out[0].category == OutcomeCategory.PARTIAL
assert out[0].unpaid == Decimal("6.00")
def test_different_member_does_not_match():
visits = [_v(date(2026, 1, 18), "J813715", "T1019", "2.32")]
svcs = [_s(date(2026, 1, 18), "OTHERMEMBER", "T1019", "2.32", "2.30")]
out = reconcile_visits_to_835(visits, svcs)
assert out[0].category == OutcomeCategory.NOT_IN_835
+44
View File
@@ -0,0 +1,44 @@
"""Summary CSV: one row per visit with its disposition."""
from datetime import date
from decimal import Decimal
from pathlib import Path
from cyclone.rebill.summary import (
SummaryRow,
write_summary_csv,
REBILLED_A, REBILLED_B, EXCLUDED_CARC, EXCLUDED_TIMELY_FILING,
EXCLUDED_PAYER, EXCLUDED_NO_EVV,
)
from cyclone.rebill.reconcile import VisitRow
def test_summary_csv_emits_one_row_per_visit(tmp_path: Path):
out = tmp_path / "summary.csv"
rows = [
SummaryRow(
visit=VisitRow(date=date(2026, 6, 27), member_id="J813715",
procedure="T1019", billed=Decimal("2.32")),
disposition=REBILLED_A, unpaid=Decimal("0.00"),
cas_reasons=("CO-45",), file_path="pipeline-a/x.837",
),
SummaryRow(
visit=VisitRow(date=date(2026, 1, 1), member_id="OLD",
procedure="T1019", billed=Decimal("2.32")),
disposition=EXCLUDED_TIMELY_FILING, unpaid=Decimal("2.32"),
cas_reasons=(), file_path="",
),
]
write_summary_csv(rows, out)
text = out.read_text()
assert "J813715" in text
assert "REBILLED_A" in text
assert "EXCLUDED_TIMELY_FILING" in text
assert "OLD" in text
def test_disposition_constants():
assert REBILLED_A == "REBILLED_A"
assert REBILLED_B == "REBILLED_B"
assert EXCLUDED_CARC == "EXCLUDED_CARC"
assert EXCLUDED_PAYER == "EXCLUDED_PAYER"
assert EXCLUDED_NO_EVV == "EXCLUDED_NO_EVV"
assert EXCLUDED_TIMELY_FILING == "EXCLUDED_TIMELY_FILING"
@@ -0,0 +1,39 @@
"""120-day DOS-age gate. CO Medicaid's timely-filing window is 120 days from DOS."""
from datetime import date
from decimal import Decimal
from cyclone.rebill.timely_filing import timely_filing_decision, FILING_WINDOW_DAYS
def test_visit_within_window_is_rebillable():
decision = timely_filing_decision(
dos=date(2026, 6, 1), as_of=date(2026, 7, 7), override=False
)
assert decision.rebillable is True
def test_visit_past_window_excluded_by_default():
decision = timely_filing_decision(
dos=date(2026, 1, 1), as_of=date(2026, 7, 7), override=False
)
assert decision.rebillable is False
assert decision.days_old == 187
def test_visit_at_exact_120_days_inclusive():
decision = timely_filing_decision(
dos=date(2026, 3, 9), as_of=date(2026, 7, 7), override=False
)
# 2026-03-09 → 2026-07-07 = 120 days
assert decision.days_old == 120
assert decision.rebillable is True
def test_override_flag_relaxes_the_gate():
decision = timely_filing_decision(
dos=date(2026, 1, 1), as_of=date(2026, 7, 7), override=True
)
assert decision.rebillable is True
def test_window_constant_is_120():
assert FILING_WINDOW_DAYS == 120
+38
View File
@@ -0,0 +1,38 @@
"""Claim-id dedup at the SFTP pre-flight stage."""
from datetime import datetime
from cyclone.store.exceptions import DuplicateClaimError
from cyclone.store.submission_dedup import (
record_submission,
check_duplicate,
DEFAULT_WINDOW_DAYS,
)
def test_check_duplicate_returns_none_for_first_submission(tmp_path):
db = tmp_path / "test.db"
assert check_duplicate("ORIG-001", db_url=f"sqlite:///{db}") is None
def test_check_duplicate_raises_within_window(tmp_path):
db = tmp_path / "test.db"
record_submission("ORIG-002", submitted_at=datetime(2026, 6, 1), db_url=f"sqlite:///{db}")
try:
check_duplicate("ORIG-002", db_url=f"sqlite:///{db}",
now=datetime(2026, 6, 15))
except DuplicateClaimError as e:
assert e.claim_id == "ORIG-002"
assert e.original_submission_at == datetime(2026, 6, 1)
else:
raise AssertionError("expected DuplicateClaimError")
def test_check_duplicate_passes_after_window(tmp_path):
db = tmp_path / "test.db"
record_submission("ORIG-003", submitted_at=datetime(2026, 1, 1), db_url=f"sqlite:///{db}")
# 100 days later — past the 30-day default window
assert check_duplicate("ORIG-003", db_url=f"sqlite:///{db}",
now=datetime(2026, 4, 11)) is None
def test_default_window_is_30_days():
assert DEFAULT_WINDOW_DAYS == 30