8.9 KiB
Sub-project 3 — Backend EDI Features: Design Spec
Date: 2026-06-20
Status: Approved
Branch: edi-features
1. Scope
Four backend EDI feature areas extending cyclone beyond the 837P parser and 835 ERA parser already shipped:
- Additional 837P validation rules — REF*G1 enforcement for adjustment-frequency claims (R034, gated on
cfg.require_ref_g1_for_adjustments); BHT06 transaction-type-code allowlist (R035, gated oncfg.allowed_bht06). - 835 CAS deep-parsing — surface CARC/RARC reason-code labels alongside the existing CAS persistence.
- 999 ACK — parse inbound 999s, serialize outbound 999s, optionally auto-generate a 999 in response to an inbound 837P.
- 270/271 eligibility — parse 270 requests and 271 responses, serialize 270 requests.
2. Out of scope (deferred to future sub-projects)
- TA1 / 997 transaction sets (Cyclone doesn't generate them today; deferred)
- Real-time payer eligibility round-trip (would require an outbound HTTP client + payer credentials)
- 999 ACK UI drilldown beyond a list page
- Per-claim ACK row linking in the DB
3. Locked decisions
3.1 CAS reason-code lookup = inline Python dict
A new parsers/cas_codes.py module with ~150 CARC codes (the most common in Colorado Medicaid remits: CO-45, CO-97, PR-1, PR-2, PR-3, CO-16, CO-29, CO-50, CO-109, CO-119, etc.). No external CSV dependency — same shape as claim_status_label(code) at parsers/models_835.py:55-71.
def reason_label(group_code: str, reason_code: str) -> str
def all_known_codes() -> list[tuple[str, str, str]] # (group, reason, label)
Includes a LAST_UPDATED = "2026-06-20" module constant for refresh tracking.
3.2 999 ACKs get a dedicated acks table
A new migration 0002_acks.sql adds the table rather than reusing batches with a kind="999" + parent_batch_id FK. Cleaner symmetry with the cas_adjustments / matches patterns. The acks table holds the response, not the source batch.
CREATE TABLE acks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_batch_id TEXT NOT NULL REFERENCES batches(id) ON DELETE CASCADE,
accepted_count INTEGER NOT NULL DEFAULT 0,
rejected_count INTEGER NOT NULL DEFAULT 0,
received_count INTEGER NOT NULL DEFAULT 0,
ack_code TEXT NOT NULL,
parsed_at DATETIME NOT NULL,
raw_json TEXT
);
CREATE INDEX ix_acks_source_batch_id ON acks(source_batch_id);
3.3 Auto-999 generation gated by ?ack=true
POST /api/parse-837 gains a new ack: bool = Query(False) parameter. When True, after store.add(record), build a 999 from the validation report:
- For each claim: AK2 → AK5 with
A(accepted) for passed validation,R(rejected) for failed,E(accepted-with-errors) for warnings. - Serialize via
serialize_999, persist the resultingAckrow. - Add
body["ack"] = { id, accepted_count, rejected_count, raw_999_text }to the JSON response.
Default behavior unchanged (no auto-ACK).
3.4 270/271 = API-only, no UI, no DB persistence
Ephemeral request/response round-trip. The operator pastes a 270 query, gets a printable 270 file, submits manually, pastes the 271 back. v1 captures the minimum useful surface (EQ inquiry + EB coverage-benefit response); anything more is a separate sub-project.
4. Schemas
4.1 999 models (parsers/models_999.py)
class ParseResult999(BaseModel):
envelope: Envelope
functional_groups: list[FunctionalGroupResponse] # AK1/AK2/AK9
class FunctionalGroupResponse(BaseModel):
ak1: AcknowledgmentHeader # identifies the source functional group
sets: list[SetFunctionalGroupResponse] # one per ST/SE set
class AcknowledgmentHeader(BaseModel):
functional_id_code: str # e.g. "HC"
group_control_number: str # from GS06
class SetFunctionalGroupResponse(BaseModel):
ak2: SetHeader | None
segment_errors: list[SegmentError]
element_errors: list[ElementError]
ak5: SegmentAcceptReject # A / R / E / W
class SegmentError(BaseModel):
ak3: SegmentContext
loop_id: str | None
segment_position: int
class ElementError(BaseModel):
ak3_ref: int # FK by row id
element_position: int
class SegmentAcceptReject(BaseModel):
code: str # A=accepted, R=rejected, E=accepted-with-errors, W=warning, X=rejected-all
class FunctionalGroupAck(BaseModel):
ak1: AcknowledgmentHeader
received_count: int # AK905
accepted_count: int # AK906
rejected_count: int # AK907
ack_code: str # AK909
4.2 270 request models (parsers/models_270.py)
class ParseResult270(BaseModel):
envelope: Envelope
information_source: InformationSource270 # 2100A — payer
information_receiver: InformationReceiver270 # 2100B — receiver
subscriber: Subscriber270 # 2000C
patient: Patient270 | None # 2100C
inquiries: list[EligibilityBenefitInquiry] # EQ segments
class EligibilityBenefitInquiry(BaseModel):
service_type_code: str # EQ01
coverage_info: str | None
class Patient270(BaseModel):
first_name: str | None
last_name: str | None
member_id: str | None
dob: date | None
gender: str | None
relationship: str | None
4.3 271 response models (parsers/models_271.py)
class ParseResult271(BaseModel):
envelope: Envelope
information_source: InformationSource271
information_receiver: InformationReceiver271
subscriber: Subscriber271
patient: Patient271 | None
coverage_benefits: list[CoverageBenefit] # EB segments, the heart of 271
class CoverageBenefit(BaseModel):
service_type_code: str # EB01
coverage_level: str # EB02 (INDividual, FAMily, etc.)
service_type_description: str | None # resolved from inline dict
benefit_amount: Decimal | None
in_plan_network: bool | None
authorization_required: bool | None
plan_coverage_description: str | None
time_qualifier: str | None
benefit_date: date | None
additional_info: list[BenefitAdditionalInfo]
4.4 acks ORM model
class Ack(Base):
__tablename__ = "acks"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
source_batch_id: Mapped[str] = mapped_column(
String(64), ForeignKey("batches.id", ondelete="CASCADE"), nullable=False,
)
accepted_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
rejected_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
received_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
ack_code: Mapped[str] = mapped_column(String(8), nullable=False)
parsed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
raw_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True)
5. API additions
| Method | Path | Body / Query | Returns |
|---|---|---|---|
| POST | /api/parse-999 |
file upload | ParseResult999 + persisted acks row |
| POST | /api/parse-837?ack=true |
file upload | existing response + body["ack"] |
| GET | /api/acks |
— | list of Ack records |
| GET | /api/acks/{id} |
— | Ack + parsed 999 detail |
| POST | /api/eligibility/request |
JSON body | raw 270 text |
| POST | /api/eligibility/parse-271 |
text body or file upload | ParseResult271 summary |
6. UI additions
- New
/ackspage (src/pages/Acks.tsx) — list view grouped by source batch with "Download 999" button. useAckshook (src/hooks/useAcks.ts).api.listAcks()+api.getAck(id)insrc/lib/api.ts.- Sidebar nav entry with
CheckCircle2icon. - Remittances page gains an "Adjustments" expansion row that lists CAS rows with reason-code labels.
No new UI for 270/271 (API-only per §3.4).
7. Migration ordering
0002_acks.sql runs after 0001_initial.sql. db_migrate.run() is idempotent — re-running on a fresh DB is a no-op. create_all is also a no-op when migration is current (per SP2 pattern). No data backfill needed (acks table is new).
8. Test coverage targets
| Phase | New tests | Total target |
|---|---|---|
| P1 — 837P rules | ~6 | ~211 backend |
| P2 — CAS deep-parsing | ~4 | ~215 backend |
| P3 — 999 ACK | ~10 backend + ~2 frontend | ~225 backend / ~10 frontend |
| P4 — 270/271 | ~4 backend | ~229 backend / ~10 frontend |
| P5 — smoke | — | (verification) |
9. Risk areas
- 270/271 scope creep. X12 specs are large; we capture only the minimum useful surface.
- 999 auto-gen semantics. "Rejected" is loosely defined; we use validation errors as the rejection signal. Document this in the OpenAPI/scalar docs.
- CARC codes drift. Inline dict is a snapshot dated 2026-06-20. Plan to refresh annually.
- Schema migration ordering. Idempotent on fresh DBs; no data backfill. Confirmed safe.