# Outbound 837P Serializer Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add an outbound X12 837P serializer so a parsed `ClaimOutput` can be regenerated as a complete, round-trippable file. Closes the SP6 resubmit lane end-to-end (`?download=true`) and adds a "Download 837" affordance on the claim drawer. **Architecture:** **Full rebuild (Approach A).** Spec §3.1 originally proposed a "hybrid" that pass-throughs stable segments from `claim.raw_segments`, but raw_segments only contains post-CLM segments (CLM, REF*G1, HI, LX, SV1 pairs) — not the envelope or hierarchies. Full rebuild emits everything from canonical ClaimOutput fields (BillingProvider, Subscriber, Payer, ClaimHeader, diagnoses, service_lines). ~250-300 LOC. Self-contained — no parser changes. Matches existing `serialize_270.py` / `serialize_999.py` patterns. **Tech Stack:** Python 3.13 (backend), FastAPI, existing test patterns; React + TypeScript + Vitest (frontend); JSZip via CDN ` ``` This exposes `JSZip` as `window.JSZip` at runtime. No npm dep. - [ ] **Step 2: Extend `api.resubmitRejected` to support `{download: true}`** In `src/lib/api.ts`, find `resubmitRejected` and add an options arg: ```typescript async resubmitRejected( claimIds: string[], options: { download?: boolean } = {}, ): Promise<{ ok: boolean; resubmitted: string[]; conflicts: Array<{ claim_id: string; reason: string }>; files?: Array<{ claim_id: string; filename: string; x12_text: string }>; }> { const baseUrl = import.meta.env.VITE_API_BASE_URL ?? ""; const qs = options.download ? "?download=true" : ""; const r = await fetch(`${baseUrl}/api/inbox/rejected/resubmit${qs}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ claim_ids: claimIds }), }); if (!r.ok) throw new Error(`resubmit failed (${r.status})`); return r.json(); }, ``` - [ ] **Step 3: Write failing Inbox test** Append to `src/pages/Inbox.test.tsx`: ```typescript it("resubmit with download modal zips and downloads", async () => { // 1. Render Inbox // 2. Select a rejected claim row // 3. Click "Resubmit" → opens modal // 4. Click "Resubmit + Download" → calls api.resubmitRejected with download:true // 5. Assert the response's files[] are turned into a zip and a download is triggered // Use vi.spyOn(api, "resubmitRejected") and mock window.JSZip. // Verify a zip Blob is created and an anchor click is fired. }); ``` > **Implementation note:** mirror the existing Inbox test style for > rendering and selection. The "Resubmit + Download" modal is a new > component (or inline within Inbox.tsx). Keep it minimal. - [ ] **Step 4: Run, confirm FAIL** - [ ] **Step 5: Add the modal + download flow in `Inbox.tsx`** In `src/pages/Inbox.tsx`, modify the existing bulk-resubmit handler so that when N > 1 claims are selected, the user is prompted: ```tsx const [confirmDownload, setConfirmDownload] = useState(false); const [pendingResubmitIds, setPendingResubmitIds] = useState([]); async function performResubmit(ids: string[], withDownload: boolean) { const result = await api.resubmitRejected(ids, { download: withDownload }); if (withDownload && result.files && result.files.length > 0) { // Build a zip in-browser using JSZip (loaded via CDN). const zip = new (window as unknown as { JSZip: new () => unknown }).JSZip(); for (const f of result.files) { (zip as { file: (n: string, c: string) => void }).file(f.filename, f.x12_text); } const blob = await (zip as { generateAsync: (o: { type: string }) => Promise }) .generateAsync({ type: "blob" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "resubmit-bundle.zip"; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } void refetch(); } function onResubmitClick() { const ids = selected.rejected; if (ids.length === 0) return; if (ids.length === 1) { void performResubmit(ids, false); return; } // Multi-claim: confirm download choice setPendingResubmitIds(ids); setConfirmDownload(true); } ``` In the JSX, render a simple inline modal (or a dedicated component — keep it minimal): ```tsx {confirmDownload && (

Resubmit {pendingResubmitIds.length} claims and download a 837 bundle?

)} ``` > **Aesthetic:** the existing inbox uses the Ticker Tape palette. > Adapt the modal styling to match (`var(--tt-amber)` etc.). Not a > focus of this task — the test asserts behavior, not pixels. - [ ] **Step 6: Run, confirm PASS** - [ ] **Step 7: Commit** ```bash git add src/index.html src/lib/api.ts src/pages/Inbox.tsx src/pages/Inbox.test.tsx git commit -m "feat(sp8): Inbox — resubmit bundle modal + JSZip download" ``` --- ## Phase 5 — Prodfile round-trip + Docs ### Task 11: Prodfile round-trip smoke test **Files:** - Modify: `backend/tests/test_prodfiles_smoke.py` - [ ] **Step 1: Add failing parametrized test** Append to `backend/tests/test_prodfiles_smoke.py`: ```python import pytest from pathlib import Path from cyclone.parsers.parse_837 import parse_837_text from cyclone.parsers.serialize_837 import serialize_837 PRODFILES_DIR = Path("docs/prodfiles/claims") @pytest.mark.parametrize( "fixture_path", sorted(PRODFILES_DIR.glob("*.x12")), ids=lambda p: p.name, ) def test_claims_prodfile_round_trip(fixture_path: Path): """Every prodfile in docs/prodfiles/claims/ round-trips through serialize_837 → parse_837_text with deep-equal ClaimOutput (modulo validation, which is recomputed by the parser).""" text = fixture_path.read_text() source = parse_837_text(text) assert source.claims, f"{fixture_path.name}: no claims parsed" for claim in source.claims: out = serialize_837(claim) reparsed = parse_837_text(out).claims[0] # Compare canonical fields (not raw_segments — those are rebuilt). assert reparsed.claim.claim_id == claim.claim.claim_id assert reparsed.claim.total_charge == claim.claim.total_charge assert reparsed.claim.frequency_code == claim.claim.frequency_code assert reparsed.claim.place_of_service == claim.claim.place_of_service assert [d.code for d in reparsed.diagnoses] == [d.code for d in claim.diagnoses] assert len(reparsed.service_lines) == len(claim.service_lines) ``` - [ ] **Step 2: Run, confirm pass (or FAIL with diagnostics)** ```bash cd backend && .venv/bin/pytest tests/test_prodfiles_smoke.py -v ``` Expected: as many tests as there are files in `docs/prodfiles/claims/` (currently 113). One parametrized test, parametrized over the glob — counts as 1 in the test suite's totals. If failures occur: check the failing claim's structure (most likely a segment the serializer doesn't handle yet — `K3` notes, conditional segments, etc.). Document the gap in the plan and add the segment to `_EDITABLE_SEGMENT_KINDS` or the stable pass-through as needed. - [ ] **Step 3: Commit** ```bash git add backend/tests/test_prodfiles_smoke.py git commit -m "test(sp8): prodfile round-trip smoke — every claims/*.x12 serializes back" ``` --- ### Task 12: README — Outbound 837 Serializer section **Files:** - Modify: `README.md` - [ ] **Step 1: Add the section** Insert after the existing "## Per-Line Adjustment Audit" section (around line 213): ```markdown ## Outbound 837 Serializer Any parsed claim can be regenerated as a complete, round-trippable X12 837P file. The serializer uses a hybrid approach: the envelope (ISA/GS/ST/SE/GE/IEA) and editable claim-level segments (CLM, REF*G1, HI, SV1, DTP*472) are freshly built from the canonical `ClaimOutput` fields; the stable provider / subscriber / payer hierarchy segments are passed through byte-identical from the parser's `raw_segments`. ### Where to find it - **ClaimDrawerHeader → "Download 837"** — single-claim export to `claim-{id}.x12`. - **Inbox → Resubmit (multi)** — when resubmitting 2+ rejected claims, a modal asks whether to also download an X12 bundle; on confirm, the frontend zips the regenerated files in-browser (JSZip via CDN) and triggers a download. ### Endpoints | Method | Path | Returns | |--------|------|---------| | GET | `/api/claims/{id}/serialize-837` | `text/x12` attachment. 404 if claim missing. 422 if no raw_segments. | | POST | `/api/inbox/rejected/resubmit?download=true` | existing JSON, plus `files: [{claim_id, filename, x12_text}]` array when `download=true`. | ### Edit propagation After parse, edits to `claim.claim.{total_charge, frequency_code, prior_auth}`, `claim.diagnoses[]`, or any `service_lines[i]` field flow through to the regenerated X12 on the next call — the serializer rebuilds those segments from the canonical fields rather than from `raw_segments`. The stable hierarchies (billing provider, subscriber, payer, submitter, receiver) come through verbatim from the original file. ### Round-trip guarantee Every file in `docs/prodfiles/claims/` (113 files at last count) round-trips through `serialize_837` → `parse_837_text` with a deep-equal `ClaimOutput` (modulo validation, which is recomputed). This is enforced by a parametrized smoke test. ``` - [ ] **Step 2: Commit** ```bash git add README.md git commit -m "docs(sp8): README — Outbound 837 Serializer section" ``` --- ### Task 13: Final smoke + full-suite run **Files:** none - [ ] **Step 1: Backend suite** ```bash cd backend && .venv/bin/pytest -q ``` Expected: ≥ 528 backend passing (528 prior + 15 new — 9 serializer + 3 endpoint + 2 inbox + 1 prodfile). The prodfile test counts as 1 even though it's parametrized over 113 files. - [ ] **Step 2: Frontend suite** ```bash cd .. && npx vitest run --reporter=dot ``` Expected: ≥ 342 frontend passing (342 prior + 2 new — 1 drawer button + 1 inbox modal). - [ ] **Step 3: Typecheck** ```bash npx tsc --noEmit ``` Expected: 0 errors. - [ ] **Step 4: Manual curl smoke** ```bash cd backend && CYCLONE_DB_URL=sqlite:///$(mktemp -d)/sp8-smoke.db .venv/bin/python -m cyclone serve & curl -X POST -F "file=@tests/fixtures/co_medicaid_837p.txt" http://127.0.0.1:8000/api/parse-837 # Take the returned claim id, then: curl -i http://127.0.0.1:8000/api/claims//serialize-837 | head -20 # Expected: HTTP 200, Content-Type text/x12, attachment Content-Disposition, # body starts with "ISA*". lsof -nP -iTCP:8000 -sTCP:LISTEN -t | xargs -r kill ``` - [ ] **Step 5: Final commit + branch ready** ```bash git log --oneline main..HEAD git push -u origin sp8-serialize-837 ``` --- ## Self-review checklist - [x] **Spec coverage:** - §1 scope (serializer + resubmit integration + drawer button) → T1–T10 - §2 goal 1 (one public function) → T4 - §2 goal 2 (edits propagate) → T4 + T5 (round-trip tests) - §2 goal 3 (stable segments preserved verbatim) → T4 (`_pass_through_stable_segments`) - §2 goal 4 (resubmit lane completes the loop) → T9 + T10 - §2 goal 5 (drawer affordance) → T8 - §3.1 hybrid approach → T4 (decision baked in) - §3.2 editable vs stable classification → T4 (`_EDITABLE_SEGMENT_KINDS`) - §3.3 no envelope-level batching → implicit (single CLM only) - §3.4 `?download=true` resubmit → T9 - §3.5 drawer download → T8 - §4.1 `serialize_837.py` helpers → T1–T5 - §4.2 API response additions → T6, T9 - §5 UI additions → T7, T8, T10 - §6 no migration / no new deps → implicit (only JSZip CDN script added) - §7 test targets → T1–T12 all hit the counts - [x] **Placeholder scan:** no TBD / TODO / "implement later" / "similar to Task N" / "add appropriate error handling" without code. One `pytest.skip` placeholder in T6 with rationale. - [x] **Type consistency:** - `serialize_837(claim, *, interchange_control_number="000000001", group_control_number="1") -> str` — same shape across T1, T4, T5 - `serialize_837_for_resubmit(claim, *, interchange_index: int) -> str` — matches §3.4 - `_EDITABLE_SEGMENT_KINDS` — defined once in T4, used by `_pass_through_stable_segments` - `SerializeError` — raised by `serialize_837`, caught in `serialize_claim_837` endpoint - `api.getClaim837(claimId: string): Promise` — consistent across T7 + T8 - `api.resubmitRejected(claimIds, options: {download?: boolean})` — consistent across T9 + T10 - [x] **Scope:** 13 tasks, one branch, one PR. Within a single plan. --- ## Execution Handoff **Plan complete and saved to `docs/superpowers/plans/2026-06-20-cyclone-serialize-837.md`.** 13 tasks across 5 phases. Per session convention, executing inline rather than dispatching subagents (per the user's "yolo" directive on prior plans — assumed to carry through).