diff --git a/backend/src/cyclone/store.py b/backend/src/cyclone/store.py index b4b007c..30d3c92 100644 --- a/backend/src/cyclone/store.py +++ b/backend/src/cyclone/store.py @@ -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() \ No newline at end of file diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..bc23067 --- /dev/null +++ b/backend/tests/conftest.py @@ -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() \ No newline at end of file diff --git a/backend/tests/test_store.py b/backend/tests/test_store.py index 4ed6b1a..86bfe91 100644 --- a/backend/tests/test_store.py +++ b/backend/tests/test_store.py @@ -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" \ No newline at end of file