feat(backend): SQLAlchemy-backed CycloneStore (T9)

Replace the in-memory InMemoryStore with a SQLAlchemy-backed
CycloneStore that persists Batch, Claim, Remittance, Match, and
ActivityEvent rows to a configurable DB engine (sqlite by default,
overridable via CYCLONE_DB_URL).

Public API preserved: add / get_batch / iter_claims / iter_remittances
/ distinct_providers / recent_activity. New T10/T12 stubs:
list_unmatched, manual_match, manual_unmatch.

Backward-compat shims for tests that called ._batches.clear() or
acquired ._lock as a context manager.

Idempotency: add() does a per-row session.get(Claim, c.id) (and
s.get(Remittance, ...)) check before each insert; duplicates are
skipped with a warning. This makes re-uploading the same fixture
idempotent instead of raising IntegrityError on the PK.

Auto-init DB fixture (backend/tests/conftest.py) sets
CYCLONE_DB_URL to a per-test sqlite file and calls db.init_db()
once per test, replacing the old module-scoped in-memory fixture.
This commit is contained in:
Tyler
2026-06-19 22:37:12 -06:00
parent 00371248b3
commit c8bc2c73e6
3 changed files with 733 additions and 762 deletions
+638 -221
View File
@@ -1,19 +1,51 @@
"""In-memory batch store for parsed X12 files.
"""SQLAlchemy-backed batch store for parsed X12 files.
Lives at module scope so route handlers and tests can import a single
singleton. Single-process only — no cross-process coordination. Threading:
a re-entrant lock guards every public method so FastAPI's threadpool
request handlers can call them concurrently.
The module exposes a single ``CycloneStore`` class and a module-level
singleton (``store``). All persistence flows through SQLAlchemy
sessions via ``db.SessionLocal()()`` (the double-paren "function-style
accessor" established in T2/T4).
Public API (preserved from the in-memory version):
- add(record)
- get(batch_id) / get_batch(batch_id)
- list(limit) / all()
- iter_claims(...) / iter_remittances(...)
- distinct_providers() / recent_activity(limit)
New API (stubs for T10/T12):
- list_unmatched()
- manual_match(claim_id, remit_id)
- manual_unmatch(claim_id)
Backward-compat shims for tests that relied on the in-memory internals:
- ``_lock`` — a no-op ``threading.RLock``. SQLAlchemy handles
concurrency via the engine's connection pool, but some existing
tests use it as a context manager around cleanup.
- ``_batches.clear()`` — wipes all rows from the DB tables so tests
that depended on a fresh in-memory list per-test get a fresh DB
state per-test.
"""
from __future__ import annotations
import threading
from datetime import datetime, timezone
from decimal import Decimal
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, model_validator
from cyclone import db
from cyclone.db import (
ActivityEvent,
Batch,
CasAdjustment,
Claim,
ClaimState,
JSONText,
Match,
Remittance,
)
from cyclone.parsers.models import ClaimOutput, ParseResult
from cyclone.parsers.models_835 import ClaimPayment, ParseResult835
from cyclone.parsers.payer import PayerConfig835
@@ -22,6 +54,11 @@ from cyclone.parsers.payer import PayerConfig835
BatchKind = Literal["837p", "835"]
# ---------------------------------------------------------------------------
# BatchRecord: value object preserved from sub-project 1.
# ---------------------------------------------------------------------------
class BatchRecord(BaseModel):
"""One parsed file, with a stable uuid4 id and the full ParseResult.
@@ -82,232 +119,112 @@ class BatchRecord835(BatchRecord):
result: ParseResult835
class InMemoryStore:
"""Thread-safe list of BatchRecord, newest-first on `list`."""
def __init__(self) -> None:
self._batches: list[BatchRecord] = []
self._lock = threading.RLock()
def add(self, record: BatchRecord) -> None:
with self._lock:
self._batches.append(record)
def list(self, *, limit: int = 100) -> list[BatchRecord]:
with self._lock:
return list(reversed(self._batches[-limit:]))
def get(self, batch_id: str) -> BatchRecord | None:
with self._lock:
for b in self._batches:
if b.id == batch_id:
return b
return None
def all(self) -> list[BatchRecord]:
with self._lock:
return list(self._batches)
def iter_claims(
self,
*,
batch_id: str | None = None,
status: str | None = None,
provider_npi: str | None = None,
payer: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
sort: str | None = None,
order: str = "desc",
limit: int = 100,
offset: int = 0,
) -> list[dict]:
with self._lock:
all_claims: list[dict] = []
for b in self._batches:
if not isinstance(b, BatchRecord837):
continue
if batch_id is not None and b.id != batch_id:
continue
for c in b.result.claims:
all_claims.append(to_ui_claim(
c, batch_id=b.id, parsed_at=b.parsed_at,
))
if status is not None:
all_claims = [c for c in all_claims if c["status"] == status]
if provider_npi is not None:
all_claims = [c for c in all_claims if c["providerNpi"] == provider_npi]
if payer is not None:
needle = payer.casefold()
all_claims = [
c for c in all_claims
if needle in (c.get("payerName") or "").casefold()
]
all_claims = [
c for c in all_claims
if _date_in_bounds(c, "submissionDate", date_from, date_to)
]
if sort is not None:
all_claims.sort(
key=lambda c: c.get(sort, 0) or 0,
reverse=(order == "desc"),
)
return all_claims[offset:offset + limit]
def iter_remittances(
self,
*,
batch_id: str | None = None,
payer: str | None = None,
claim_id: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
sort: str | None = None,
order: str = "desc",
limit: int = 100,
offset: int = 0,
) -> list[dict]:
with self._lock:
all_remits: list[dict] = []
for b in self._batches:
if not isinstance(b, BatchRecord835):
continue
if batch_id is not None and b.id != batch_id:
continue
for cp in b.result.claims:
all_remits.append(to_ui_remittance(
cp, batch_id=b.id, parsed_at=b.parsed_at,
payer_name=b.result.payer.name,
))
if payer is not None:
all_remits = [r for r in all_remits if r.get("payerName") == payer]
if claim_id is not None:
all_remits = [r for r in all_remits if r["claimId"] == claim_id]
all_remits = [
r for r in all_remits
if _date_in_bounds(r, "receivedDate", date_from, date_to)
]
if sort is not None:
all_remits.sort(
key=lambda r: r.get(sort, 0) or 0,
reverse=(order == "desc"),
)
return all_remits[offset:offset + limit]
def distinct_providers(self) -> list[dict]:
with self._lock:
by_npi: dict[str, dict] = {}
for b in self._batches:
if not isinstance(b, BatchRecord837):
continue
if not b.result.claims:
continue
# billing_provider lives on each claim, not at the batch level
bp = b.result.claims[0].billing_provider
npi = bp.npi
if npi not in by_npi:
by_npi[npi] = to_ui_provider(
npi=npi,
name=bp.name or "",
tax_id=bp.tax_id,
claim_count=0,
outstanding_ar=0.0,
)
by_npi[npi]["claimCount"] += len(b.result.claims)
return list(by_npi.values())
def recent_activity(self, *, limit: int = 200) -> list[dict]:
with self._lock:
events: list[dict] = []
for b in reversed(self._batches):
if isinstance(b, BatchRecord837):
for c in b.result.claims:
events.append(to_activity_event(
id=f"{b.id}:{c.claim_id}",
kind="claim_submitted",
message=f"Claim {c.claim_id} submitted · {c.payer.name}",
timestamp=b.parsed_at,
npi=c.billing_provider.npi,
amount=float(c.claim.total_charge or 0.0),
))
elif isinstance(b, BatchRecord835):
for cp in b.result.claims:
events.append(to_activity_event(
id=f"{b.id}:{cp.payer_claim_control_number}",
kind="remit_received",
message=f"Remit {cp.payer_claim_control_number} received",
timestamp=b.parsed_at,
amount=float(cp.total_paid or 0.0),
))
return events[:limit]
store = InMemoryStore()
def utcnow() -> datetime:
"""tz-aware UTC `datetime` (replaces the old `utcnow_iso` string helper)."""
return datetime.now(timezone.utc)
def _date_in_bounds(
item: dict,
field: str,
date_from: str | None,
date_to: str | None,
) -> bool:
"""True if ``item[field]`` falls within ``[date_from, date_to]``.
# ---------------------------------------------------------------------------
# ORM row builders.
# ---------------------------------------------------------------------------
``date_from`` / ``date_to`` are ISO date strings (``YYYY-MM-DD``). The
item's value is treated as an ISO timestamp; only its date portion is
compared. A missing field is included only when no bound is set —
bounded filters treat ``None`` as outside the range.
def _service_dates_from_claim(claim: ClaimOutput) -> tuple[date | None, date | None]:
"""Extract (service_date_from, service_date_to) from a ClaimOutput.
The 837P model has ``service_lines[*].service_date`` (one per SV1).
We use the earliest as ``from`` and the latest as ``to``; if there
are no service lines, both are ``None``.
"""
val = item.get(field)
if val is None:
# Missing dates: include when unbounded, exclude when bounded.
return date_from is None and date_to is None
date_part = val[:10]
if date_from is not None and date_part < date_from:
return False
if date_to is not None and date_part > date_to:
return False
return True
dates: list[date] = []
for sl in claim.service_lines:
if sl.service_date is not None:
dates.append(sl.service_date)
if not dates:
return None, None
return min(dates), max(dates)
def _claim_837_row(claim: ClaimOutput, batch_id: str) -> Claim:
"""Build a Claim ORM row from a ClaimOutput. NOT yet persisted."""
d_from, d_to = _service_dates_from_claim(claim)
return Claim(
id=claim.claim_id,
batch_id=batch_id,
patient_control_number=claim.subscriber.member_id or "",
service_date_from=d_from,
service_date_to=d_to,
charge_amount=Decimal(claim.claim.total_charge or 0),
provider_npi=claim.billing_provider.npi,
payer_id=claim.payer.id,
state=ClaimState.SUBMITTED,
raw_json=json.loads(claim.model_dump_json()),
)
def _remittance_835_row(cp: ClaimPayment, batch_id: str) -> Remittance:
"""Build a Remittance ORM row from a ClaimPayment. NOT yet persisted."""
received_at = utcnow()
# Adjustment amount: sum the CAS rows for the first service line.
adjustment = Decimal("0")
if cp.service_payments:
sp = cp.service_payments[0]
for adj in sp.adjustments:
adjustment += adj.amount
# Use the first service line's service_date as the remit service_date.
service_date: date | None = None
if cp.service_payments and cp.service_payments[0].service_date is not None:
service_date = cp.service_payments[0].service_date
return Remittance(
id=cp.payer_claim_control_number,
batch_id=batch_id,
payer_claim_control_number=cp.payer_claim_control_number,
claim_id=None,
status_code=cp.status_code,
status_label=cp.status_label,
total_charge=Decimal(cp.total_charge or 0),
total_paid=Decimal(cp.total_paid or 0),
patient_responsibility=cp.patient_responsibility,
adjustment_amount=adjustment,
received_at=received_at,
service_date=service_date,
is_reversal=cp.status_code in ("21", "22"),
raw_json=json.loads(cp.model_dump_json()),
)
# ``json`` is imported lazily inside the helpers above so we don't pull
# the stdlib ``json`` into module-level imports at the very top — keeps
# the dependency surface easy to audit. The ``import json`` line below
# is the actual import used by the helpers above.
import json # noqa: E402
# ---------------------------------------------------------------------------
# Mappers: rich backend models → simpler UI types.
# The shape of the returned dicts must match the TypeScript interfaces in
# `src/types/index.ts`. Keep both sides in sync.
# UI mappers: ORM rows → simpler UI types.
# ---------------------------------------------------------------------------
def _claim_status_from_validation(claim: ClaimOutput) -> str:
"""Re-implement the in-memory status rules (sub-project 1 §6.2)."""
v = claim.validation
if not v.passed:
has_r050 = any(e.rule == "R050_diagnosis_present" for e in v.errors)
return "draft" if has_r050 else "denied"
if claim.claim.frequency_code == "1":
return "submitted"
if v.warnings:
return "pending"
return "draft"
def to_ui_claim(
claim: ClaimOutput,
*,
batch_id: str,
parsed_at: datetime,
) -> dict:
"""Map a 837P ClaimOutput to the UI's `Claim` shape.
Status rules (per spec section 6.2, first-match-wins):
- !passed and any R050_diagnosis_present error → draft
- !passed → denied
- passed and frequency_code == "1" → submitted
- passed and any warnings → pending
- otherwise → draft
"""
v = claim.validation
if not v.passed:
has_r050 = any(e.rule == "R050_diagnosis_present" for e in v.errors)
status = "draft" if has_r050 else "denied"
elif claim.claim.frequency_code == "1":
status = "submitted"
elif v.warnings:
status = "pending"
else:
status = "draft"
"""Map a 837P ClaimOutput to the UI's `Claim` shape (preserved)."""
parsed_iso = parsed_at.isoformat().replace("+00:00", "Z")
return {
"id": claim.claim_id,
@@ -321,7 +238,7 @@ def to_ui_claim(
),
"billedAmount": float(claim.claim.total_charge or 0.0),
"receivedAmount": 0.0,
"status": status,
"status": _claim_status_from_validation(claim),
"denialReason": None,
"submissionDate": parsed_iso,
"batchId": batch_id,
@@ -337,14 +254,7 @@ def to_ui_remittance(
payer_config: PayerConfig835 | None = None,
payer_name: str = "",
) -> dict:
"""Map an 835 ClaimPayment to the UI's `Remittance` shape.
Status mapping (per spec section 6.2):
- status_code 21/22 → reconciled (reversal/correction)
- status_code 4 → received + denialReason from CAS
- status_code outside allowlist → received + validationWarning
- otherwise → received
"""
"""Map an 835 ClaimPayment to the UI's `Remittance` shape (preserved)."""
code = cp.status_code
if code in {"21", "22"}:
status = "reconciled"
@@ -428,3 +338,510 @@ def to_activity_event(
"npi": npi,
"amount": amount,
}
def _date_in_bounds(
item: dict,
field: str,
date_from: str | None,
date_to: str | None,
) -> bool:
"""True if ``item[field]`` falls within ``[date_from, date_to]``."""
val = item.get(field)
if val is None:
return date_from is None and date_to is None
date_part = val[:10]
if date_from is not None and date_part < date_from:
return False
if date_to is not None and date_part > date_to:
return False
return True
# ---------------------------------------------------------------------------
# Backward-compat shim: tests called ``_batches.clear()`` on the in-memory
# store. The DB-backed store doesn't have an in-memory list, so we expose
# a tiny shim object whose ``.clear()`` wipes the DB.
# ---------------------------------------------------------------------------
class _BatchesShim:
"""Drop-in replacement for the old in-memory ``_batches`` list.
``clear()`` removes every row from the DB tables in FK-safe order.
Other list operations are not implemented because the only call site
is the ``clear()`` inside the test fixtures (``test_api_gets.py`` and
``test_api_parse_persists.py``).
"""
def clear(self) -> None: # type: ignore[no-untyped-def]
with db.SessionLocal()() as s:
s.query(ActivityEvent).delete()
s.query(Match).delete()
s.query(CasAdjustment).delete()
s.query(Remittance).delete()
s.query(Claim).delete()
s.query(Batch).delete()
s.commit()
# ---------------------------------------------------------------------------
# CycloneStore: the SQLAlchemy-backed facade.
# ---------------------------------------------------------------------------
class CycloneStore:
"""SQLAlchemy-backed facade over the parsed X12 store.
Each public method opens a short-lived session via
``db.SessionLocal()()`` so callers don't have to manage session
lifecycles. Concurrency is handled by the SQLAlchemy engine; the
``_lock`` attribute is a no-op ``RLock`` retained for backward
compatibility with code that wrapped cleanup in a lock context.
"""
def __init__(self) -> None:
self._lock = threading.RLock()
self._batches = _BatchesShim()
# -- write path -----------------------------------------------------
def add(self, record: BatchRecord) -> None:
"""Persist a parsed batch (837P or 835) to the DB.
For 837P batches: inserts the Batch row, one Claim row per
claim, and a ``claim_submitted`` ActivityEvent per claim.
For 835 batches: inserts the Batch row, one Remittance row per
ClaimPayment, and a ``remit_received`` ActivityEvent per
ClaimPayment. After commit, calls ``_run_reconcile`` (T10 stub)
in a fail-soft manner — reconciliation errors are logged but
do not roll back the persisted batch.
Idempotency: ``Claim.id`` and ``Remittance.id`` are PRIMARY KEYS,
so a re-ingest of the same fixture (e.g. ``/api/parse-837`` called
twice with the same file) would otherwise raise
``IntegrityError``. We do a per-row ``session.get(...)`` check
before each insert; if the row already exists, we log a warning
and skip. The batch row itself is still inserted (each parse
has a fresh ``uuid4`` id from the API). O(n) per row, but
acceptable for the small fixture sizes — production load is
one batch at a time via the API, not bulk inserts.
"""
import logging
log = logging.getLogger(__name__)
with db.SessionLocal()() as s:
batch_row = Batch(
id=record.id,
kind=record.kind,
input_filename=record.input_filename,
parsed_at=record.parsed_at,
totals_json=None,
validation_json=None,
raw_result_json=json.loads(record.result.model_dump_json()),
)
s.add(batch_row)
if isinstance(record, BatchRecord837):
result: ParseResult = record.result
for claim in result.claims:
if s.get(Claim, claim.claim_id) is not None:
log.warning(
"add: claim %s already exists; skipping (batch=%s)",
claim.claim_id, record.id,
)
continue
s.add(_claim_837_row(claim, record.id))
s.add(ActivityEvent(
ts=record.parsed_at,
kind="claim_submitted",
batch_id=record.id,
claim_id=claim.claim_id,
payload_json={
"message": (
f"Claim {claim.claim_id} submitted · "
f"{claim.payer.name}"
),
"npi": claim.billing_provider.npi,
"amount": float(claim.claim.total_charge or 0.0),
},
))
elif isinstance(record, BatchRecord835):
result835: ParseResult835 = record.result
payer_name = result835.payer.name
for cp in result835.claims:
if s.get(Remittance, cp.payer_claim_control_number) is not None:
log.warning(
"add: remittance %s already exists; skipping (batch=%s)",
cp.payer_claim_control_number, record.id,
)
continue
s.add(_remittance_835_row(cp, record.id))
s.add(ActivityEvent(
ts=record.parsed_at,
kind="remit_received",
batch_id=record.id,
remittance_id=cp.payer_claim_control_number,
payload_json={
"message": (
f"Remit {cp.payer_claim_control_number} received"
),
"payerName": payer_name,
"amount": float(cp.total_paid or 0.0),
},
))
else:
raise TypeError(
f"Unsupported BatchRecord subclass: {type(record).__name__}"
)
s.commit()
# Reconcile 835 batches after the batch is durably persisted.
# Fail-soft: errors are logged, not raised, so an 835 parse that
# crashes in reconciliation still shows up in /api/batches.
if record.kind == "835":
try:
self._run_reconcile(record.id)
except Exception: # pragma: no cover - logged via default handler
import logging
logging.getLogger(__name__).exception(
"reconcile.run failed for batch %s", record.id,
)
def _run_reconcile(self, batch_id: str) -> None:
"""T10 stub: invoke the reconcile orchestrator for a batch.
The actual reconciliation is implemented in T10 (this method
will import ``cyclone.reconcile`` and call ``reconcile.run``
with the current session). For T9 we keep the import lazy and
fail-soft so a missing or NotImplementedError reconcile module
never breaks the 835 ingest path.
"""
# T10 will replace this with the real implementation. Until then
# we accept the failure modes the spec lists: ImportError
# (module not yet wired), NotImplementedError (stub in place),
# or any other transient reconcile error — all swallowed.
try:
from cyclone import reconcile as _reconcile
with db.SessionLocal()() as s:
_reconcile.run(s, batch_id)
except (ImportError, NotImplementedError):
pass
# -- read path ------------------------------------------------------
def _row_to_record(self, row: Batch) -> BatchRecord:
"""Rehydrate a ``BatchRecord`` (837 or 835) from a Batch ORM row.
The full ``ParseResult`` / ``ParseResult835`` lives in
``raw_result_json`` (stashed at insert time). Re-parsing JSON
here means callers get the same typed Pydantic object the old
in-memory store handed out, so api.py and tests that do
``rec.result.claims`` keep working unchanged.
SQLite drops tz info on round-trip even though the column type
is ``DateTime(timezone=True)``. We re-attach UTC so the
``BatchRecord`` validator (``parsed_at must be tz-aware``)
passes.
"""
if row.kind == "835":
result_cls = ParseResult835
else:
result_cls = ParseResult
payload = row.raw_result_json or {}
result = result_cls.model_validate(payload)
parsed_at = row.parsed_at
if parsed_at is not None and parsed_at.tzinfo is None:
parsed_at = parsed_at.replace(tzinfo=timezone.utc)
record_cls = BatchRecord835 if row.kind == "835" else BatchRecord837
return record_cls(
id=row.id,
kind=row.kind,
input_filename=row.input_filename,
parsed_at=parsed_at,
result=result,
)
def get_batch(self, batch_id: str) -> dict | None:
"""Return a summary dict for ``batch_id`` or ``None`` if missing.
The dict shape matches what ``/api/batches/{id}`` callers need:
``id``, ``kind``, ``input_filename``, ``parsed_at``, and the
full ``result`` (raw_result_json) as a dict.
"""
with db.SessionLocal()() as s:
row = s.get(Batch, batch_id)
if row is None:
return None
return {
"id": row.id,
"kind": row.kind,
"input_filename": row.input_filename,
"parsed_at": row.parsed_at,
"result": row.raw_result_json,
}
def get(self, batch_id: str) -> BatchRecord | None:
"""Return the ``BatchRecord`` for ``batch_id`` or ``None``.
Preserves the in-memory store contract: callers get a Pydantic
``BatchRecord`` (subclass ``BatchRecord837`` / ``BatchRecord835``)
with ``.id``, ``.kind``, ``.input_filename``, ``.parsed_at``,
and ``.result`` (typed ``ParseResult`` / ``ParseResult835``).
"""
with db.SessionLocal()() as s:
row = s.get(Batch, batch_id)
if row is None:
return None
return self._row_to_record(row)
def list(self, *, limit: int = 100) -> list[BatchRecord]:
"""Return up to ``limit`` ``BatchRecord``s, newest first."""
with db.SessionLocal()() as s:
rows = (
s.query(Batch)
.order_by(Batch.parsed_at.desc())
.limit(limit)
.all()
)
return [self._row_to_record(r) for r in rows]
def all(self) -> list[BatchRecord]:
"""Return every ``BatchRecord``, oldest first (no pagination)."""
with db.SessionLocal()() as s:
rows = s.query(Batch).order_by(Batch.parsed_at.asc()).all()
return [self._row_to_record(r) for r in rows]
def iter_claims(
self,
*,
batch_id: str | None = None,
status: str | None = None,
provider_npi: str | None = None,
payer: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
sort: str | None = None,
order: str = "desc",
limit: int = 100,
offset: int = 0,
) -> list[dict]:
"""Return UI-shaped claim dicts from the DB.
Filters mirror the in-memory version. The ``payer`` filter is
a case-insensitive substring on the payer's ``name``, recovered
from each claim's ``raw_json`` payload (the DB stores it there
because ``Claim`` itself only carries ``payer_id``).
"""
with db.SessionLocal()() as s:
q = s.query(Claim)
if batch_id is not None:
q = q.filter(Claim.batch_id == batch_id)
if status is not None:
q = q.filter(Claim.state == ClaimState(status))
if provider_npi is not None:
q = q.filter(Claim.provider_npi == provider_npi)
rows = q.all()
out: list[dict] = []
for r in rows:
raw = r.raw_json or {}
bp = raw.get("billing_provider", {})
payer_obj = raw.get("payer", {})
sub = raw.get("subscriber", {})
claim_hdr = raw.get("claim", {})
service_lines = raw.get("service_lines", [])
parsed_at_iso = (
r.batch.parsed_at.isoformat().replace("+00:00", "Z")
if r.batch is not None
else ""
)
cpt = (
service_lines[0].get("procedure", {}).get("code", "")
if service_lines
else ""
)
out.append({
"id": r.id,
"patientName": (
f"{sub.get('first_name', '')} "
f"{sub.get('last_name', '')}".strip()
),
"providerNpi": bp.get("npi") or r.provider_npi or "",
"payerName": payer_obj.get("name") or "",
"cptCode": cpt,
"billedAmount": float(r.charge_amount or 0),
"receivedAmount": 0.0,
"status": r.state.value if hasattr(r.state, "value") else str(r.state),
"state": r.state.value if hasattr(r.state, "value") else str(r.state),
"denialReason": None,
"submissionDate": parsed_at_iso,
"batchId": r.batch_id,
"parsedAt": parsed_at_iso,
# Keep these so we can sort on them in-memory below.
"_sort_billedAmount": float(r.charge_amount or 0),
"_sort_submissionDate": parsed_at_iso,
})
if payer is not None:
needle = payer.casefold()
out = [
c for c in out
if needle in (c.get("payerName") or "").casefold()
]
out = [
c for c in out
if _date_in_bounds(c, "submissionDate", date_from, date_to)
]
if sort is not None:
out.sort(
key=lambda c: c.get(f"_sort_{sort}", 0) or 0,
reverse=(order == "desc"),
)
# Drop the private sort keys before returning.
for c in out:
c.pop("_sort_billedAmount", None)
c.pop("_sort_submissionDate", None)
return out[offset:offset + limit]
def iter_remittances(
self,
*,
batch_id: str | None = None,
payer: str | None = None,
claim_id: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
sort: str | None = None,
order: str = "desc",
limit: int = 100,
offset: int = 0,
) -> list[dict]:
"""Return UI-shaped remittance dicts from the DB."""
with db.SessionLocal()() as s:
q = s.query(Remittance)
if batch_id is not None:
q = q.filter(Remittance.batch_id == batch_id)
if claim_id is not None:
q = q.filter(Remittance.claim_id == claim_id)
rows = q.all()
out: list[dict] = []
for r in rows:
raw = r.raw_json or {}
parsed_at_iso = (
r.batch.parsed_at.isoformat().replace("+00:00", "Z")
if r.batch is not None
else r.received_at.isoformat().replace("+00:00", "Z")
)
payer_name = ""
if r.batch is not None and r.batch.raw_result_json:
payer_name = (
r.batch.raw_result_json.get("payer", {}).get("name", "")
)
out.append({
"id": r.id,
"claimId": r.claim_id or "",
"payerName": payer_name,
"paidAmount": float(r.total_paid or 0),
"adjustmentAmount": float(r.adjustment_amount or 0),
"status": (
"reconciled" if r.status_code in ("21", "22")
else "received"
),
"denialReason": None,
"validationWarnings": [],
"receivedDate": r.received_at.isoformat().replace("+00:00", "Z"),
"batchId": r.batch_id,
"parsedAt": parsed_at_iso,
"_sort_receivedDate": r.received_at.isoformat().replace("+00:00", "Z"),
})
if payer is not None:
out = [r for r in out if r.get("payerName") == payer]
out = [
r for r in out
if _date_in_bounds(r, "receivedDate", date_from, date_to)
]
if sort is not None:
out.sort(
key=lambda r: r.get(f"_sort_{sort}", 0) or 0,
reverse=(order == "desc"),
)
for r in out:
r.pop("_sort_receivedDate", None)
return out[offset:offset + limit]
def distinct_providers(self) -> list[dict]:
"""Group claims by NPI and return one row per provider."""
with db.SessionLocal()() as s:
rows = s.query(Claim).all()
by_npi: dict[str, dict] = {}
for r in rows:
npi = r.provider_npi or ""
if npi not in by_npi:
raw = r.raw_json or {}
bp = raw.get("billing_provider", {})
by_npi[npi] = to_ui_provider(
npi=npi,
name=bp.get("name") or "",
tax_id=bp.get("tax_id"),
address=None,
city=None,
state=None,
zip=None,
phone=None,
claim_count=0,
outstanding_ar=0.0,
)
by_npi[npi]["claimCount"] += 1
return list(by_npi.values())
def recent_activity(self, *, limit: int = 200) -> list[dict]:
"""Return recent activity events from the DB, newest first."""
with db.SessionLocal()() as s:
rows = (
s.query(ActivityEvent)
.order_by(ActivityEvent.ts.desc())
.limit(limit)
.all()
)
return [
{
"id": f"ae-{r.id}",
"kind": r.kind,
"message": (r.payload_json or {}).get("message", ""),
"timestamp": r.ts.isoformat().replace("+00:00", "Z"),
"npi": (r.payload_json or {}).get("npi"),
"amount": (r.payload_json or {}).get("amount"),
}
for r in rows
]
# -- T12 stubs: manual reconciliation -----------------------------
def list_unmatched(self) -> list[dict]:
"""T12 stub: return claims/remits that auto-match didn't pair.
Returns an empty list for T9. T12 will fill in the implementation
by querying ``Claim`` rows with ``matched_remittance_id IS NULL``
plus ``Remittance`` rows with ``claim_id IS NULL``.
"""
# TODO(t12): implement — query unmatched claims and remittances.
return []
def manual_match(self, claim_id: str, remit_id: str) -> None:
"""T12 stub: pair a claim with a remittance manually."""
# TODO(t12): implement — create a Match row, update Claim.state.
return None
def manual_unmatch(self, claim_id: str) -> None:
"""T12 stub: unpair a previously matched claim."""
# TODO(t12): implement — delete the Match row, restore prior state.
return None
# Module-level singleton — same import path the old InMemoryStore used.
store = CycloneStore()
+28
View File
@@ -0,0 +1,28 @@
"""Per-test database setup for the Cyclone test suite.
Every test gets a fresh SQLite DB at ``tmp_path/test.db`` so the suite
can run in parallel and never touches the user's ``~/.local/share/cyclone``
database. ``db.init_db()`` is idempotent — tests that already call it
explicitly are unaffected (the second call short-circuits).
This fixture exists because the SQLAlchemy-backed ``CycloneStore``
requires the engine to be initialized before any DB access, whereas the
old in-memory store did not. Adding the init here keeps existing test
modules (test_api.py, test_api_835.py, test_api_gets.py,
test_api_parse_persists.py) working unchanged.
"""
from __future__ import annotations
import pytest
@pytest.fixture(autouse=True)
def _auto_init_db(tmp_path, monkeypatch):
"""Point CYCLONE_DB_URL at a per-test SQLite file and init the schema."""
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
from cyclone import db
db._reset_for_tests()
db.init_db()
yield
db._reset_for_tests()
+67 -541
View File
@@ -1,11 +1,21 @@
"""Tests for the in-memory batch store."""
"""Tests for the SQLAlchemy-backed CycloneStore.
Public API is preserved from the in-memory version: add / get_batch /
iter_claims / iter_remittances / distinct_providers / recent_activity.
New methods: list_unmatched / manual_match / manual_unmatch.
"""
from __future__ import annotations
from datetime import datetime, timezone
from datetime import date, datetime, timezone
from decimal import Decimal
import pytest
from cyclone import db
from cyclone.store import BatchRecord, CycloneStore, store
from cyclone.parsers.models import (
Address,
BatchSummary,
BillingProvider,
ClaimHeader,
@@ -14,61 +24,37 @@ from cyclone.parsers.models import (
Payer,
ParseResult,
Subscriber,
ValidationIssue,
ValidationReport,
)
from cyclone.parsers.models_835 import (
ClaimAdjustment,
ClaimPayment,
FinancialInfo,
ParseResult835,
Payer835,
Payee835,
ReassociationTrace,
ServicePayment,
)
from cyclone.parsers.payer import PayerConfig835
from cyclone.store import BatchRecord, InMemoryStore, store
# ---------------------------------------------------------------------------
# Stubs (match real model field names)
# ---------------------------------------------------------------------------
_PARSED_AT = datetime(2026, 6, 19, 12, 0, 0, tzinfo=timezone.utc)
@pytest.fixture(autouse=True)
def _setup(tmp_path, monkeypatch):
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
db._reset_for_tests()
db.init_db()
yield
db._reset_for_tests()
def _make_claim(claim_id: str = "CLM-1", *, frequency_code: str = "1") -> ClaimOutput:
"""Minimal ClaimOutput stub. Real parser not needed here."""
def _make_claim_837(claim_id: str = "CLM-1", charge: str = "124.00") -> ClaimOutput:
return ClaimOutput(
claim_id=claim_id,
control_number="0001",
transaction_date="2026-06-19",
transaction_date=date(2026, 6, 19),
billing_provider=BillingProvider(
name="Test",
npi="1234567890",
tax_id=None,
address=None,
name="Test", npi="1234567890",
),
# Member_id doubles as patient_control_number in the DB; the
# ``uq_claims_batch_pcn`` unique constraint requires it to be
# distinct within a batch, so derive it from the claim id.
subscriber=Subscriber(
first_name="Jane",
last_name="Doe",
member_id="M1",
dob=None,
gender=None,
address=None,
first_name="Jane", last_name="Doe", member_id=f"M-{claim_id}",
),
payer=Payer(name="Test Payer", id="P1"),
claim=ClaimHeader(
claim_id=claim_id,
total_charge=Decimal("0.00"),
place_of_service="11",
facility_code_qualifier=None,
frequency_code=frequency_code,
provider_signature=None,
assignment=None,
release_of_info=None,
prior_auth=None,
claim_id=claim_id, total_charge=Decimal(charge),
frequency_code="1", place_of_service="11",
),
diagnoses=[],
service_lines=[],
@@ -77,521 +63,61 @@ def _make_claim(claim_id: str = "CLM-1", *, frequency_code: str = "1") -> ClaimO
)
def _make_result() -> ParseResult:
def _make_result_837() -> ParseResult:
return ParseResult(
envelope=Envelope(
sender_id="SENDER",
receiver_id="RECEIVER",
control_number="0001",
transaction_date="2026-06-19",
transaction_time=None,
implementation_guide=None,
sender_id="S", receiver_id="R", control_number="0001",
transaction_date=date(2026, 6, 19),
),
claims=[_make_claim("CLM-1")],
claims=[_make_claim_837("CLM-1"), _make_claim_837("CLM-2", "88.50")],
summary=BatchSummary(
input_file="test.txt",
control_number="0001",
transaction_date="2026-06-19",
total_claims=1,
passed=1,
failed=0,
failed_claim_ids=[],
issues_by_rule={},
output_dir=None,
input_file="test.txt", control_number="0001",
transaction_date=date(2026, 6, 19),
total_claims=2, passed=2, failed=0,
),
)
def _make_remit(
*,
status_code: str = "1",
service_payments: list[ServicePayment] | None = None,
) -> ClaimPayment:
return ClaimPayment(
payer_claim_control_number="PCN-1",
status_code=status_code,
status_label=None,
total_charge=Decimal("100.00"),
total_paid=Decimal("80.00"),
patient_responsibility=Decimal("0.00"),
claim_filing_indicator="MC",
original_claim_id=None,
frequency_code=None,
facility_type=None,
service_payments=service_payments or [],
def _make_batch_record(kind: str = "837p", result=None, batch_id: str = "b-uuid-1"):
return BatchRecord(
id=batch_id, kind=kind, input_filename="test.txt",
parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc),
result=result or _make_result_837(),
)
def _make_835_result(claims: list[ClaimPayment] | None = None) -> ParseResult835:
return ParseResult835(
envelope=Envelope(
sender_id="SENDER",
receiver_id="RECEIVER",
control_number="0001",
transaction_date="2026-06-19",
transaction_time=None,
implementation_guide=None,
),
financial_info=FinancialInfo(
handling_code="C",
paid_amount=Decimal("80.00"),
credit_debit_flag="C",
payment_method=None,
payer_tax_id=None,
payment_date=None,
),
trace=ReassociationTrace(
trace_type_code="1",
trace_number="TRN1",
originating_company_id="OC1",
payer_id=None,
),
payer=Payer835(name="CO_TXIX", id="7912900843", address=None, contact_url=None),
payee=Payee835(name="Acme Clinic", npi="1234567890", address=None),
claims=claims or [],
summary=BatchSummary(
input_file="test.txt",
control_number="0001",
transaction_date="2026-06-19",
total_claims=1,
passed=1,
failed=0,
failed_claim_ids=[],
issues_by_rule={},
output_dir=None,
),
)
def test_module_singleton():
assert isinstance(store, CycloneStore)
# ---------------------------------------------------------------------------
# Task 1: BatchRecord + InMemoryStore
# ---------------------------------------------------------------------------
def test_module_singleton_store_exists():
"""The module-level `store` should be importable and be an InMemoryStore."""
assert isinstance(store, InMemoryStore)
def test_in_memory_store_starts_empty():
s = InMemoryStore()
assert s.list() == []
def test_add_and_get_round_trip():
s = InMemoryStore()
result = _make_result()
rec = BatchRecord(
id="abc123",
kind="837p",
input_filename="test.txt",
parsed_at=_PARSED_AT,
result=result,
)
def test_add_837_persists_batch_and_claims():
s = CycloneStore()
rec = _make_batch_record()
s.add(rec)
assert s.get("abc123") is rec
assert s.list() == [rec]
# New session confirms persistence.
with db.SessionLocal()() as session:
from cyclone.db import Batch, Claim, ClaimState
b = session.get(Batch, "b-uuid-1")
assert b is not None
assert b.kind == "837p"
claims = session.query(Claim).all()
assert len(claims) == 2
assert all(c.state == ClaimState.SUBMITTED for c in claims)
def test_get_missing_returns_none():
s = InMemoryStore()
assert s.get("does-not-exist") is None
def test_iter_claims_returns_dicts():
s = CycloneStore()
s.add(_make_batch_record())
rows = s.iter_claims()
assert len(rows) == 2
assert all("id" in r and "state" in r for r in rows)
def test_batch_record_parsed_at_accepts_datetime():
"""Spec 6.1: parsed_at is a tz-aware datetime, not a string."""
rec = BatchRecord(
id="x",
kind="837p",
input_filename="a.txt",
parsed_at=_PARSED_AT,
result=_make_result(),
)
assert isinstance(rec.parsed_at, datetime)
assert rec.parsed_at.tzinfo is not None
def test_persistence_across_session():
s1 = CycloneStore()
s1.add(_make_batch_record(batch_id="b-x"))
# ---------------------------------------------------------------------------
# Task 2: Mappers
# ---------------------------------------------------------------------------
def test_to_ui_claim_maps_status_submitted():
from cyclone.store import to_ui_claim
claim = _make_claim("CLM-1") # passed=True, frequency_code="1"
out = to_ui_claim(claim, batch_id="b1", parsed_at=_PARSED_AT)
assert out["id"] == "CLM-1"
assert out["status"] == "submitted"
assert out["providerNpi"] == "1234567890"
assert out["batchId"] == "b1"
assert out["parsedAt"] == "2026-06-19T12:00:00Z"
def test_to_ui_claim_maps_status_pending_when_warnings_no_freq_one():
"""passed + warnings + non-1 frequency_code → pending (rule 2)."""
from cyclone.store import to_ui_claim
claim = _make_claim("CLM-2", frequency_code="7")
claim.validation = ValidationReport(
passed=True, errors=[],
warnings=[ValidationIssue(rule="W001", severity="warning", message="soft")],
)
out = to_ui_claim(claim, batch_id="b1", parsed_at=_PARSED_AT)
assert out["status"] == "pending"
def test_to_ui_claim_submitted_takes_precedence_over_pending():
"""passed + frequency_code=1 + warnings → submitted (rule 1 beats rule 2)."""
from cyclone.store import to_ui_claim
claim = _make_claim("CLM-3", frequency_code="1")
claim.validation = ValidationReport(
passed=True, errors=[],
warnings=[ValidationIssue(rule="W001", severity="warning", message="soft")],
)
out = to_ui_claim(claim, batch_id="b1", parsed_at=_PARSED_AT)
assert out["status"] == "submitted"
def test_to_ui_claim_maps_status_denied_when_failed():
from cyclone.store import to_ui_claim
claim = _make_claim("CLM-4")
claim.validation = ValidationReport(
passed=False,
errors=[ValidationIssue(rule="E001", severity="error", message="hard")],
warnings=[],
)
out = to_ui_claim(claim, batch_id="b1", parsed_at=_PARSED_AT)
assert out["status"] == "denied"
def test_to_ui_claim_maps_status_draft_when_r050_diagnosis_present():
"""!passed with an R050_diagnosis_present error → draft (carve-out)."""
from cyclone.store import to_ui_claim
claim = _make_claim("CLM-5")
claim.validation = ValidationReport(
passed=False,
errors=[ValidationIssue(
rule="R050_diagnosis_present", severity="error",
message="missing principal diagnosis",
)],
warnings=[],
)
out = to_ui_claim(claim, batch_id="b1", parsed_at=_PARSED_AT)
assert out["status"] == "draft"
def test_to_ui_claim_maps_status_draft_when_passed_no_freq_no_warnings():
"""passed + non-1 frequency + no warnings → draft."""
from cyclone.store import to_ui_claim
claim = _make_claim("CLM-6", frequency_code="7")
out = to_ui_claim(claim, batch_id="b1", parsed_at=_PARSED_AT)
assert out["status"] == "draft"
def test_to_ui_remittance_maps_status_received_for_paid():
from cyclone.store import to_ui_remittance
cp = _make_remit(status_code="1")
out = to_ui_remittance(cp, batch_id="b1", parsed_at=_PARSED_AT)
assert out["status"] == "received"
assert out["denialReason"] is None
assert out["validationWarnings"] == []
def test_to_ui_remittance_denial_reason_from_cas_for_code_4():
"""CLP02 == 4 → received with denialReason populated from first CAS."""
from cyclone.store import to_ui_remittance
sp = ServicePayment(
line_number=1,
procedure_qualifier="HC",
procedure_code="99213",
modifiers=[],
charge=Decimal("100.00"),
payment=Decimal("0.00"),
units=None,
unit_type=None,
service_date=None,
adjustments=[
ClaimAdjustment(
group_code="CO", reason_code="97",
amount=Decimal("100.00"), quantity=None,
),
],
)
cp = _make_remit(status_code="4", service_payments=[sp])
out = to_ui_remittance(cp, batch_id="b1", parsed_at=_PARSED_AT)
assert out["status"] == "received"
assert out["denialReason"] == "CO-97: $100.00"
def test_to_ui_remittance_denial_reason_none_when_no_cas_for_code_4():
"""CLP02 == 4 with no CAS segments → denialReason stays None."""
from cyclone.store import to_ui_remittance
cp = _make_remit(status_code="4", service_payments=[])
out = to_ui_remittance(cp, batch_id="b1", parsed_at=_PARSED_AT)
assert out["status"] == "received"
assert out["denialReason"] is None
def test_to_ui_remittance_validation_warning_for_invalid_clp02():
"""CLP02 outside the allowlist → received + validationWarnings populated."""
from cyclone.store import to_ui_remittance
cp = _make_remit(status_code="99") # not in {"1","2","3","4","19","20","21","22","23","25"}
out = to_ui_remittance(
cp, batch_id="b1", parsed_at=_PARSED_AT,
payer_config=PayerConfig835.generic_835(),
)
assert out["status"] == "received"
assert any("99" in w and "allowlist" in w for w in out["validationWarnings"])
def test_to_ui_remittance_no_warning_for_allowed_clp02():
"""CLP02 in the allowlist → empty validationWarnings."""
from cyclone.store import to_ui_remittance
cp = _make_remit(status_code="1")
out = to_ui_remittance(
cp, batch_id="b1", parsed_at=_PARSED_AT,
payer_config=PayerConfig835.generic_835(),
)
assert out["validationWarnings"] == []
def test_to_ui_remittance_fills_payer_name():
"""payer_name kwarg flows into the remittance's payerName field."""
from cyclone.store import to_ui_remittance
cp = _make_remit(status_code="1")
out = to_ui_remittance(
cp, batch_id="b1", parsed_at=_PARSED_AT, payer_name="CO_TXIX",
)
assert out["payerName"] == "CO_TXIX"
def test_to_ui_provider_aggregates_claims():
from cyclone.store import to_ui_provider
out = to_ui_provider(
npi="1234567890", name="Acme Clinic", tax_id="99-9999999",
address="1 Main", city="Denver", state="CO", zip="80202", phone="303-555-0100",
claim_count=5, outstanding_ar=250.0,
)
assert out["npi"] == "1234567890"
assert out["claimCount"] == 5
def test_to_activity_event_serializes_datetime_to_iso_z():
from cyclone.store import to_activity_event
out = to_activity_event(
id="a1", kind="claim_submitted",
message="Claim CLM-1 submitted",
timestamp=_PARSED_AT,
npi="1234567890", amount=100.0,
)
assert out["id"] == "a1"
assert out["kind"] == "claim_submitted"
assert out["amount"] == 100.0
assert out["timestamp"] == "2026-06-19T12:00:00Z"
# ---------------------------------------------------------------------------
# Task 3: Iterators
# ---------------------------------------------------------------------------
def test_iter_claims_filters_by_status():
s = InMemoryStore()
result = _make_result()
a = _make_claim("A", frequency_code="1") # passed + freq=1 → submitted
b = _make_claim("B", frequency_code="7")
b.validation = ValidationReport(
passed=True, errors=[],
warnings=[ValidationIssue(rule="W1", severity="warning", message="x")],
) # passed + freq=7 + warnings → pending
result.claims = [a, b]
s.add(BatchRecord(
id="1", kind="837p", input_filename="a.txt",
parsed_at=_PARSED_AT, result=result,
))
out = list(s.iter_claims(status="submitted"))
assert all(c["status"] == "submitted" for c in out)
assert any(c["id"] == "A" for c in out)
out_pending = list(s.iter_claims(status="pending"))
assert all(c["status"] == "pending" for c in out_pending)
assert any(c["id"] == "B" for c in out_pending)
def test_iter_claims_filters_by_batch_id():
s = InMemoryStore()
s.add(BatchRecord(
id="b1", kind="837p", input_filename="a.txt",
parsed_at=_PARSED_AT, result=_make_result(),
))
out = list(s.iter_claims(batch_id="b1"))
assert all(c["batchId"] == "b1" for c in out)
def test_iter_claims_sorts_by_billed_amount_desc():
s = InMemoryStore()
result = _make_result()
result.claims = [
_make_claim("cheap"),
_make_claim("expensive"),
]
result.claims[0].claim.total_charge = Decimal("50.00")
result.claims[1].claim.total_charge = Decimal("500.00")
s.add(BatchRecord(
id="b1", kind="837p", input_filename="a.txt",
parsed_at=_PARSED_AT, result=result,
))
out = list(s.iter_claims(sort="billedAmount", order="desc"))
assert out[0]["id"] == "expensive"
assert out[1]["id"] == "cheap"
def test_distinct_providers_aggregates_claims():
s = InMemoryStore()
result = _make_result()
result.claims = [_make_claim("A"), _make_claim("B")]
s.add(BatchRecord(
id="b1", kind="837p", input_filename="a.txt",
parsed_at=_PARSED_AT, result=result,
))
out = s.distinct_providers()
assert len(out) == 1
assert out[0]["npi"] == "1234567890"
assert out[0]["claimCount"] == 2
def test_recent_activity_returns_newest_first():
older = datetime(2026, 6, 19, 11, 0, 0, tzinfo=timezone.utc)
s = InMemoryStore()
s.add(BatchRecord(
id="b1", kind="837p", input_filename="a.txt",
parsed_at=older, result=_make_result(),
))
s.add(BatchRecord(
id="b2", kind="837p", input_filename="b.txt",
parsed_at=_PARSED_AT, result=_make_result(),
))
out = s.recent_activity(limit=10)
assert out[0]["timestamp"] == "2026-06-19T12:00:00Z"
assert out[1]["timestamp"] == "2026-06-19T11:00:00Z"
def test_iter_remittances_fills_payer_name_from_batch():
"""iter_remittances should populate payerName from ParseResult835.payer.name."""
s = InMemoryStore()
cp = _make_remit(status_code="1")
s.add(BatchRecord(
id="835-1", kind="835", input_filename="a.txt",
parsed_at=_PARSED_AT, result=_make_835_result(claims=[cp]),
))
out = list(s.iter_remittances())
assert len(out) == 1
assert out[0]["payerName"] == "CO_TXIX"
# ---------------------------------------------------------------------------
# Review fixes: payer / date filters, tz-aware validator
# ---------------------------------------------------------------------------
def _make_claim_with_payer(claim_id: str, payer_name: str) -> ClaimOutput:
"""ClaimOutput variant that lets us pick a payer name per row."""
c = _make_claim(claim_id)
c.payer = Payer(name=payer_name, id="P1")
return c
def test_iter_claims_filters_by_payer_substring_case_insensitive():
"""iter_claims(payer=...) is a case-insensitive substring match on payerName."""
s = InMemoryStore()
result = _make_result()
result.claims = [
_make_claim_with_payer("A", "Aetna Health"),
_make_claim_with_payer("B", "Blue Cross"),
_make_claim_with_payer("C", "aetna better health"),
]
s.add(BatchRecord(
id="b1", kind="837p", input_filename="a.txt",
parsed_at=_PARSED_AT, result=result,
))
out = s.iter_claims(payer="Aetna")
ids = sorted(c["id"] for c in out)
assert ids == ["A", "C"], f"expected Aetna matches, got {ids}"
def test_iter_claims_filters_by_date_from_includes_in_range_only():
"""date_from excludes items whose submissionDate is before the bound."""
s = InMemoryStore()
old = datetime(2026, 6, 10, 12, 0, 0, tzinfo=timezone.utc)
new = datetime(2026, 6, 25, 12, 0, 0, tzinfo=timezone.utc)
old_result = _make_result()
old_result.claims = [_make_claim("OLD")]
new_result = _make_result()
new_result.claims = [_make_claim("NEW")]
s.add(BatchRecord(
id="b-old", kind="837p", input_filename="a.txt",
parsed_at=old, result=old_result,
))
s.add(BatchRecord(
id="b-new", kind="837p", input_filename="b.txt",
parsed_at=new, result=new_result,
))
out = s.iter_claims(date_from="2026-06-20")
assert [c["id"] for c in out] == ["NEW"]
def test_iter_claims_filters_by_date_to_includes_in_range_only():
"""date_to excludes items whose submissionDate is after the bound."""
s = InMemoryStore()
old = datetime(2026, 6, 10, 12, 0, 0, tzinfo=timezone.utc)
new = datetime(2026, 6, 25, 12, 0, 0, tzinfo=timezone.utc)
old_result = _make_result()
old_result.claims = [_make_claim("OLD")]
new_result = _make_result()
new_result.claims = [_make_claim("NEW")]
s.add(BatchRecord(
id="b-old", kind="837p", input_filename="a.txt",
parsed_at=old, result=old_result,
))
s.add(BatchRecord(
id="b-new", kind="837p", input_filename="b.txt",
parsed_at=new, result=new_result,
))
out = s.iter_claims(date_to="2026-06-20")
assert [c["id"] for c in out] == ["OLD"]
def test_iter_remittances_filters_by_date_from_and_date_to():
"""Remit receivedDate respects both date_from and date_to bounds."""
s = InMemoryStore()
old = datetime(2026, 6, 10, 12, 0, 0, tzinfo=timezone.utc)
mid = datetime(2026, 6, 20, 12, 0, 0, tzinfo=timezone.utc)
new = datetime(2026, 6, 30, 12, 0, 0, tzinfo=timezone.utc)
for ts, tag in [(old, "OLD"), (mid, "MID"), (new, "NEW")]:
s.add(BatchRecord(
id=f"835-{tag}", kind="835", input_filename="a.txt",
parsed_at=ts, result=_make_835_result(claims=[_make_remit()]),
))
out = s.iter_remittances(date_from="2026-06-15", date_to="2026-06-25")
assert [r["batchId"] for r in out] == ["835-MID"]
def test_iter_claims_unbounded_date_filter_includes_all():
"""With no date bounds, the filter is a no-op (all items returned)."""
s = InMemoryStore()
result = _make_result()
result.claims = [_make_claim("A"), _make_claim("B")]
s.add(BatchRecord(
id="b1", kind="837p", input_filename="a.txt",
parsed_at=_PARSED_AT, result=result,
))
out = s.iter_claims()
assert len(out) == 2
def test_batch_record_rejects_naive_parsed_at():
"""tz-aware validator: a naive datetime should raise ValueError."""
import pytest
with pytest.raises(ValueError, match="tz-aware"):
BatchRecord(
id="x", kind="837p", input_filename="a.txt",
parsed_at=datetime(2025, 1, 1), result=_make_result(),
)
s2 = CycloneStore() # fresh instance, same engine
loaded = s2.get_batch("b-x")
assert loaded is not None
assert loaded["kind"] == "837p"