diff --git a/docs/CMS-10114 Form.pdf b/docs/CMS-10114 Form.pdf new file mode 100644 index 0000000..882dcb7 Binary files /dev/null and b/docs/CMS-10114 Form.pdf differ diff --git a/docs/healthcare-filing-tiers-verified.md b/docs/healthcare-filing-tiers-verified.md index f69bc3a..3c9fb76 100644 --- a/docs/healthcare-filing-tiers-verified.md +++ b/docs/healthcare-filing-tiers-verified.md @@ -49,22 +49,42 @@ Internal capability map. Confirms how each healthcare service is fulfilled with - Paper-to-MAC, original signatures, routed by state. "Sign and date section 8 using ink." -### NPPES data update / NPI reactivation (CMS-10114 paper path) [PARTIAL] -- 855I confirms NPI is obtained/changed via NPPES and "you may apply **online** at - NPPES.cms.hhs.gov" — implies the online path; the paper CMS-10114 to the NPI - Enumerator (PO Box 6059, Fargo ND 58108-6059) is the documented paper - alternative per the existing `healthcare-no-login-value-add.md` (NPI Enumerator - 800-465-3203). -- **UNCERTAIN (verify before relying on it as the sole standard path):** whether - CMS still accepts paper CMS-10114 for *updates/reactivation* (vs initial - enumeration only) today. Could not reach the live CMS-10114 instruction from - this host (search engines blocked, direct URLs 404). -- **Decision:** for NPPES-only services, offer Expedited (surrogate → file in - NPPES online) as the primary fast path, and the CMS-10114 paper path as the - Standard fallback. Confirm the CMS-10114-for-changes acceptance + obtain the - current form PDF before building `cms10114_pdf_filler.py`. Until confirmed, the - honest Standard fallback for a declined-surrogate NPPES update may be "we - prepare it and guide the one-step online submission" — TBD on verification. +### NPPES data update / NPI reactivation (CMS-10114 paper path) [CONFIRMED] +- **VERIFIED firsthand** against the live CMS-10114 PDF (Rev. 02/25, OMB + 0938-0931, exp. 03/2028) downloaded from + `cms.gov/Medicare/CMS-Forms/CMS-Forms/downloads/CMS10114.pdf`, and the CMS + "How to Apply" page which titles it the **"NPI Application/Update Form"**. +- The form's Section 1A "Reason for Submittal" has four checkboxes: + **#1 Initial Application, #2 Change of Information, #3 Deactivation, + #4 Reactivation** — all completed on the SAME paper form, each requiring a + signed + dated certification (Section 4A individual / 4B organization). + Verbatim: *"If changing information, check box #2... then sign and date the + certification... All changes must be reported to the NPI Enumerator within 30 + days."* and *"If you are reactivating the NPI, check box #4... Sign and date + the certification."* +- So paper CMS-10114 is a valid no-login path for **changes AND reactivation**, + not just initial enumeration. UNCERTAIN flag resolved. +- **Mailing address (current, printed on the form page 5):** + ``` + CMS NPI Enumerator Services + Mail Stop DO-01-51 + 7500 Security Blvd. + Baltimore, MD 21244 + ``` + The previously-documented **Fargo, ND PO Box 6059 address is RETIRED** — code + (`mac_routing.NPI_ENUMERATOR`) and docs updated to the Baltimore address. + Enumerator phone for questions: 1-800-465-3203. +- **No electronic no-login equivalent exists for CMS.** Unlike FMCSA (whose + `ask.fmcsa.dot.gov/app/ticket` accepts an MCS-150 update + attachments with no + account), CMS has no public web form to submit an NPPES change without a login. + Probed live: the NPI Registry API (`npiregistry.cms.hhs.gov/api`) is + **read-only lookup**; PECOS and NPPES I&A both require login. Therefore for + NPPES changes the two genuine tiers are: **Standard = paper CMS-10114 to + Baltimore (no login)**, **Expedited = surrogate files in NPPES online**. +- **Decision (updated):** offer Expedited (surrogate → NPPES online) as the fast + path and the CMS-10114 paper-to-Baltimore path as the Standard default. Build + `cms10114_pdf_filler.py` to produce the signable form (Reason checkbox driven + by service: change vs reactivation vs deactivation). ## CMS I&A Surrogate (Expedited path) - Provider adds Performance West as a **Surrogate** in CMS I&A; we then file in @@ -78,14 +98,14 @@ Internal capability map. Confirms how each healthcare service is fulfilled with | Medicare revalidation | 855I/B → MAC, client signs | file in PECOS | 855B yes | | Medicare enrollment | 855I/B/O → MAC, client signs | file in PECOS | 855B yes | | NPI reactivation | 855I → MAC (reactivation reason) | PECOS/NPPES | no | -| NPPES data update | CMS-10114 → Fargo *(verify)* | NPPES online | no | +| NPPES data update | CMS-10114 → NPI Enumerator (Baltimore), client signs | NPPES online | no | | OIG/SAM screening | public DBs, **zero client action** | n/a (no portal) | no | | Provider compliance bundle | spawns reval + screening + NPPES | mixed | per-piece | ## Daily batch (Standard-path mailing) Each **postal working day morning** (skip weekends + USPS/federal holidays — use `scripts/workers/business_days.py` calendar): gather all signed+pending paper -filings, **group by destination agency** (each MAC; NPI Enumerator Fargo; each +filings, **group by destination agency** (each MAC; NPI Enumerator Baltimore; each state agency), merge each group into ONE print job + a cover sheet (PW sender, destination, date, enclosed count, per-item order#/provider/NPI/form), one **Priority Mail** envelope per agency. Mark each order mailed with batch date + @@ -100,9 +120,17 @@ Certification. Need a **state → MAC → mailing-address** table to address env ## Implementation status (built + validated) - **mac_routing.py** — state→MAC (56 jurisdictions, 12 destinations) + - NPI_ENUMERATOR (Fargo). Addresses marked VERIFY before first live mail. + NPI_ENUMERATOR (NPI Enumerator Services, Baltimore MD — VERIFIED from CMS-10114 + Rev. 02/25). MAC addresses still marked VERIFY before first live mail. - **npi_provider.py** — two-tier `access` strings; NPPES update/reactivation no - longer "online-only"; surrogate answer surfaced in the admin todo. + longer "online-only"; surrogate answer surfaced in the admin todo. nppes-update + now generates the CMS-10114 for e-signature (Standard path) via + `_generate_10114_for_signing`. +- **cms10114_pdf_filler.py** — fills the official CMS-10114 (flat PDF, text + overlay): reason checkbox (change/reactivation/deactivation) + NPI + Section 2A + identity + Section 4A certification name, with a signature anchor on the cert + line. The signed form joins the daily batch to the NPI Enumerator (Baltimore). + `test_cms10114.py` 27/27 pass. - **checkout.ts + service pages + intake** — client-facing copy stripped of mechanics; surrogate is the only optional, positively-framed ask (faster, never required, never share password, never mentions paper). Astro build green. @@ -118,9 +146,12 @@ Certification. Need a **state → MAC → mailing-address** table to address env ## TODO before first live mail (manual / verify) 1. Fill the real MAC provider-enrollment PO Box addresses in `mac_routing.py` (marked VERIFY) from each MAC's current enrollment page. -2. Confirm CMS-10114 paper-for-changes acceptance + obtain the form PDF, then - build `cms10114_pdf_filler.py` for the NPPES Standard path (until then - nppes-update falls to surrogate/manual). +2. ~~Confirm CMS-10114 paper-for-changes acceptance + obtain the form PDF~~ + ~~build `cms10114_pdf_filler.py`~~ **DONE** — verified against CMS-10114 + Rev. 02/25 (address = NPI Enumerator Services, Baltimore MD); filler built + + wired into `npi_provider._generate_10114_for_signing`; `test_cms10114.py` + 27/27 pass. Remaining nicety: org (Entity Type 2 / Section 4B) overlay path + (currently flagged for manual completion when NPI-2 detected). 3. Run migration 089 on the DB; confirm the worker picks up a signed test filing and produces the per-agency cover + merged PDF in MinIO. 4. Phase 2: wire a print-mail API (Lob/Click2Mail) to auto-mail the merged PDF diff --git a/docs/healthcare-no-login-value-add.md b/docs/healthcare-no-login-value-add.md index c214c1f..07a4d49 100644 --- a/docs/healthcare-no-login-value-add.md +++ b/docs/healthcare-no-login-value-add.md @@ -12,7 +12,7 @@ single paper form (or e-sign), and we file it by mail or as the preparer. | Service | Paper/no-login path | Provider's only job | Verified source | |---|---|---|---| | **Medicare PECOS Revalidation** | CMS-855I/855B/855A **paper** application mailed to the provider's **MAC** (revalidation is a checkbox reason in Section 1A). | **Sign** the form (wet/original signature). We prepare 100% of it. | CMS-855I: "submit your application with **original signatures**… to your designated MAC… by mail." | -| **NPPES / NPI data update** (address, taxonomy, contact) | **CMS-10114** paper NPI Application/Update mailed to the **NPI Enumerator, PO Box 6059, Fargo, ND 58108-6059**; Enumerator staff key it into NPPES. | Sign the paper form. **No NPPES/I&A login at all.** | NPI Enumerator paper process (PO Box 6059, Fargo ND; 800-465-3203). | +| **NPPES / NPI data update** (address, taxonomy, contact) | **CMS-10114** paper NPI Application/Update mailed to the **NPI Enumerator (CMS NPI Enumerator Services, 7500 Security Blvd, Baltimore, MD 21244)**; Enumerator staff key it into NPPES. | Sign the paper form. **No NPPES/I&A login at all.** | NPI Enumerator paper process (Baltimore, MD; 800-465-3203; CMS-10114 Rev. 02/25). | | **NPI Reactivation / enrollment reactivation** | Same paper-855 path to the MAC (reactivation is a Section 1A reason). | Sign. | CMS-855 instructions. | | **Medicare Enrollment (new)** | Paper CMS-855I/855B to the MAC with original signature. | Sign + provide info. | CMS-855I. | | **OIG / SAM Exclusion Screening** | We screen against the **public** OIG LEIE + SAM.gov databases by name/NPI. | **Nothing — zero provider involvement.** No login, no signature. | OIG LEIE + SAM.gov are public federal databases. | @@ -39,7 +39,7 @@ time of navigating CMS portals. Our differentiator: > website. (For exclusion screening you don't even sign — we just do it.) ## Mailing destinations (for credibility in copy) -- NPI/NPPES paper: **NPI Enumerator, PO Box 6059, Fargo, ND 58108-6059** +- NPI/NPPES paper: **CMS NPI Enumerator Services, Mail Stop DO-01-51, 7500 Security Blvd, Baltimore, MD 21244** - 855 enrollment/revalidation: the provider's **MAC** (varies by state/jurisdiction; we determine and route it). diff --git a/docs/plans/Plan.md b/docs/plans/Plan.md index 0417587..76a0125 100644 --- a/docs/plans/Plan.md +++ b/docs/plans/Plan.md @@ -5,7 +5,7 @@ the "paper" alternative is NEVER surfaced to the client): - **Standard service (default):** we handle the filing end-to-end. Client signs ONE certification and we submit + track to confirmation. (Internally this is the - paper path — 855 to the MAC, CMS-10114 to the NPI Enumerator in Fargo — but we + paper path — 855 to the MAC, CMS-10114 to the NPI Enumerator (Baltimore MD) — but we never say "paper" to the client; it's just "we file it for you.") - **Expedited (faster, framed positively at intake):** we ask if they can **electronically grant us CMS I&A Surrogate access**. We position this as @@ -28,11 +28,11 @@ surrogate as *required* when a paper path exists; paper is always the default. - `api/src/routes/checkout.ts` (~L2155-2207) — already has the two-tier copy, BUT treats NPPES update/reactivation as **"online-only, surrogate required"** with NO standard fallback (`NPPES_ONLY_SLUGS`, `hasNppesOnly`). The directive says: - give those a Standard paper path too (CMS-10114 to Fargo) and make surrogate the + give those a Standard paper path too (CMS-10114 to NPI Enumerator (Baltimore MD)) and make surrogate the *expedited* option, not the only option. - `scripts/workers/services/npi_provider.py` — `access` strings call NPPES update/reactivation "NPPES via CMS I&A surrogate access (online-only)". Must - become "Standard: CMS-10114 paper to NPI Enumerator (Fargo), client signs; + become "Standard: CMS-10114 paper to NPI Enumerator (Baltimore MD), client signs; Expedited: NPPES via I&A surrogate." Also `_STANDARD_FILING_SLUGS` currently excludes `nppes-update` (it has no 855) — needs a CMS-10114 paper path instead. - `scripts/document_gen/templates/cms855_pdf_filler.py` — working 855 paper path @@ -58,7 +58,7 @@ pricing changes. ## Approach (concrete ordered steps) 1. **Confirm the CMS-10114 paper path** for NPPES data updates AND NPI reactivation (not just initial enumeration): paper CMS-10114 mailed to NPI - Enumerator (PO Box 6059, Fargo ND), client signature only, no I&A login. + Enumerator (Baltimore MD), client signature only, no I&A login. Cite the official source. This is what makes NPPES services "Standard, no login" instead of surrogate-required. (We currently have no CMS-10114 PDF.) 2. **Lock the two-tier matrix for the 6 federal services.** For each slug record: @@ -81,13 +81,13 @@ pricing changes. paper + e-sign, and the admin todo reflects Standard-default / Expedited-if- surrogate-granted. Mirror the existing 855 generate→upload→esign flow. 5. **MAC + Fargo routing rule.** Document which Standard filings go where - (855 → provider's MAC by state/jurisdiction; CMS-10114 → Fargo). Confirm + (855 → provider's MAC by state/jurisdiction; CMS-10114 → NPI Enumerator (Baltimore MD)). Confirm `practice_state` intake field drives MAC envelope addressing. 7. **Daily batched-mail fulfillment (Standard path).** Design the operational flow for paper filings that are signed + pending submission: - On each **postal working day morning** (skip weekends + federal/USPS holidays), gather ALL signed-and-pending paper filings, **group by - destination agency/address** (each MAC, the NPI Enumerator in Fargo, each + destination agency/address** (each MAC, the NPI Enumerator (Baltimore MD), each state Medicaid/CLIA agency). - For each destination, **merge all that day's documents into one print job** plus a **cover sheet** (Performance West sender, destination agency, date, @@ -129,7 +129,7 @@ pricing changes. and Standard is the default on every surface. - **Standard path mechanism check:** `cms855_pdf_filler.fill_cms855("855i",...)` still yields filled PDF + cert anchor (read-only smoke render). If CMS-10114 - filler is built, same smoke render proves a signable Fargo-bound PDF. + filler is built, same smoke render proves a signable Baltimore-bound PDF. - **Expedited path check:** confirm the surrogate-grant CTA + `ia_surrogacy` success action + admin todo together let a human file in PECOS/NPPES same-day; surrogate scope wording matches what CMS I&A actually grants (PECOS vs NPPES). @@ -138,11 +138,11 @@ pricing changes. no "unknown" and no service that's portal-only-but-marketed-as-no-login. ## Open questions / decisions -1. **NPPES update/reactivation Standard path:** is CMS-10114 paper-to-Fargo - accepted for *changes/reactivation* today (RESOLVE in step 1)? If yes, both - become Standard-default + surrogate-expedited. If CMS has gone online-only, - they stay surrogate-required and we say so honestly. (Directive assumes paper - works as Standard; step 1 verifies.) +1. **NPPES update/reactivation Standard path:** ✅ RESOLVED — CMS-10114 paper IS + accepted for *changes AND reactivation* (verified against CMS-10114 Rev. 02/25, + Section 1A boxes #2 Change / #4 Reactivation). Mailing address is the NPI + Enumerator in **Baltimore MD** (the old Fargo PO Box 6059 is retired). Both + nppes-update and npi-reactivation are Standard-default + surrogate-expedited. 2. **Build `cms10114_pdf_filler.py` this pass?** Needed for a real NPPES Standard path. Recommend: yes if step 1 confirms paper, mirroring the 855 filler; otherwise defer. diff --git a/scripts/document_gen/templates/cms10114_pdf_filler.py b/scripts/document_gen/templates/cms10114_pdf_filler.py new file mode 100644 index 0000000..8a2c65b --- /dev/null +++ b/scripts/document_gen/templates/cms10114_pdf_filler.py @@ -0,0 +1,253 @@ +"""Fill the official CMS-10114 NPI Application/Update form (flat PDF, overlay). + +The CMS-10114 is the "NPI Application/Update Form" (Rev. 02/25, OMB 0938-0931). +Unlike the CMS-855 forms it is a **flat PDF with no AcroForm fields**, so we +fill it by drawing a text overlay at fixed coordinates (reportlab) and merging +that over the original page (pypdf), mirroring the stamper approach used by the +e-sign pipeline. + +This is the Standard (no-login) path for NPPES data updates, NPI reactivation, +and deactivation: the provider signs the certification (Section 4A individual / +4B organization) and the signed form is mailed to: + + CMS NPI Enumerator Services + Mail Stop DO-01-51 + 7500 Security Blvd. + Baltimore, MD 21244 + +(verified against the form, page 5; the older Fargo PO Box 6059 is retired). +Enumerator staff key the data into NPPES; no NPPES/I&A login is required. + +Section 1A "Reason for Submittal" has four mutually-exclusive checkboxes: + 1 Initial Application, 2 Change of Information, 3 Deactivation, 4 Reactivation. +We drive the checkbox from the service slug / intake reason so the same filler +serves nppes-update (change), npi-reactivation, and deactivation. + +Usage: + from scripts.document_gen.templates.cms10114_pdf_filler import fill_cms10114 + pdf_bytes, anchors, missing = fill_cms10114(intake, reason="change", + order_number="CO-1234") +""" +from __future__ import annotations + +import io +import logging +from pathlib import Path + +LOG = logging.getLogger("cms10114_pdf_filler") + +# docs/ lives two levels up from scripts/document_gen/templates/ +DOCS_DIR = Path(__file__).resolve().parents[3] / "docs" +FORM_PATH = DOCS_DIR / "CMS-10114 Form.pdf" + +# Page index (0-based) of the actual fill-in application form pages within the +# downloaded PDF (the first pages are instructions). +PAGE_BASIC = 2 # Section 1 (reason + entity) and Section 2A identity +PAGE_CERT = 4 # Section 4 certification / signature + +PAGE_W = 612.0 +PAGE_H = 792.0 + +# Reason-for-submittal checkbox glyph positions (Section 1A). The checkbox sits +# just left of each label; coordinates are the lower-left of where we draw an +# "X". Verified from pdfplumber word boxes on the form page. +REASON_CHECKBOX = { + "initial": {"x": 48, "y": 610}, + "change": {"x": 48, "y": 584}, + "deactivation": {"x": 325, "y": 610}, + "reactivation": {"x": 325, "y": 508}, +} + +# Where to write the NPI for each reason (the "NPI: (Required)" line beside the +# chosen checkbox). Change uses the left column NPI line; deactivation/ +# reactivation use the right column NPI lines. +REASON_NPI_POS = { + "change": {"x": 132, "y": 570}, + "deactivation": {"x": 408, "y": 599}, + "reactivation": {"x": 408, "y": 529}, +} + +# Section 2A individual identity fields (the blank row under the Prefix/First/ +# Middle/Last header at pdf_y ~320; we write on the entry row just below it). +IDENTITY_POS = { + "first": {"x": 168, "y": 305}, + "middle": {"x": 281, "y": 305}, + "last": {"x": 370, "y": 305}, +} + +# Section 4A (individual) printed-name fields under "First*/Middle/Last*" header. +CERT_NAME_POS = { + "first": {"x": 142, "y": 372}, + "middle": {"x": 280, "y": 372}, + "last": {"x": 429, "y": 372}, +} + +# Signature line for Section 4A (individual). The label "Signature (First, +# Middle, Last...)" sits at pdf_y ~458; the blank signing line is the row above +# it. The e-sign stamper places the provider's signature here. +SIGNATURE_FIELDS = [ + {"field": "signer", "page": PAGE_CERT, + "rect": [60.0, 470.0, 470.0, 490.0], "page_w": PAGE_W, "page_h": PAGE_H}, +] + +VALID_REASONS = ("initial", "change", "deactivation", "reactivation") + +# Map service slug -> default reason, so callers can pass a slug instead. +SLUG_TO_REASON = { + "nppes-update": "change", + "npi-data-update": "change", + "npi-reactivation": "reactivation", + "npi-deactivation": "deactivation", + "npi-enumeration": "initial", +} + + +def _split_name(full: str) -> tuple[str, str, str]: + parts = (full or "").split() + if not parts: + return "", "", "" + if len(parts) == 1: + return parts[0], "", "" + if len(parts) == 2: + return parts[0], "", parts[1] + return parts[0], parts[1][0], " ".join(parts[2:]) + + +def normalize_reason(reason_or_slug: str) -> str: + r = (reason_or_slug or "").strip().lower() + if r in VALID_REASONS: + return r + return SLUG_TO_REASON.get(r, "change") + + +def _build_overlay(reason: str, values: dict) -> bytes: + """Render a multi-page overlay matching the form's page count.""" + from reportlab.lib.pagesizes import letter + from reportlab.pdfgen import canvas + + buf = io.BytesIO() + c = canvas.Canvas(buf, pagesize=letter) + + def draw(x, y, text, size=9): + if not text: + return + c.setFont("Helvetica", size) + c.drawString(x, y, str(text)) + + # We only draw on PAGE_BASIC and PAGE_CERT; emit blank pages for the rest so + # page indices line up when merging. + n_pages = max(PAGE_BASIC, PAGE_CERT) + 1 + for pi in range(n_pages): + if pi == PAGE_BASIC: + cb = REASON_CHECKBOX.get(reason) + if cb: + draw(cb["x"], cb["y"], "X", size=11) + npi_pos = REASON_NPI_POS.get(reason) + if npi_pos and values.get("npi"): + draw(npi_pos["x"], npi_pos["y"], values["npi"]) + draw(IDENTITY_POS["first"]["x"], IDENTITY_POS["first"]["y"], values.get("first")) + draw(IDENTITY_POS["middle"]["x"], IDENTITY_POS["middle"]["y"], values.get("middle")) + draw(IDENTITY_POS["last"]["x"], IDENTITY_POS["last"]["y"], values.get("last")) + elif pi == PAGE_CERT: + draw(CERT_NAME_POS["first"]["x"], CERT_NAME_POS["first"]["y"], values.get("first")) + draw(CERT_NAME_POS["middle"]["x"], CERT_NAME_POS["middle"]["y"], values.get("middle")) + draw(CERT_NAME_POS["last"]["x"], CERT_NAME_POS["last"]["y"], values.get("last")) + c.showPage() + c.save() + return buf.getvalue() + + +def fill_cms10114(intake: dict, reason: str = "change", + order_number: str = "") -> tuple[bytes, list[dict], list[str]]: + """Fill the official CMS-10114 form via text overlay. + + Args: + intake: dict with provider_name/first_name/last_name, npi, practice_state. + reason: one of initial/change/deactivation/reactivation, OR a service + slug (nppes-update, npi-reactivation, ...) which is normalized. + order_number: for logging/traceability only. + + Returns (pdf_bytes, signature_anchors, missing_notes). + """ + try: + from pypdf import PdfReader, PdfWriter + except ImportError as exc: # pragma: no cover + raise RuntimeError("pypdf is required to fill CMS-10114") from exc + + if not FORM_PATH.exists(): + raise FileNotFoundError(f"Official CMS-10114 not found: {FORM_PATH}") + + reason = normalize_reason(reason) + + provider = intake.get("provider_name", "") + f, m, l = _split_name(provider) + values = { + "first": intake.get("first_name", f), + "middle": intake.get("middle_initial", m), + "last": intake.get("last_name", l), + "npi": (intake.get("npi") or "").strip(), + } + + overlay_bytes = _build_overlay(reason, values) + + base = PdfReader(str(FORM_PATH)) + overlay = PdfReader(io.BytesIO(overlay_bytes)) + writer = PdfWriter() + + for i, page in enumerate(base.pages): + if i < len(overlay.pages): + page.merge_page(overlay.pages[i]) + writer.add_page(page) + + buf = io.BytesIO() + writer.write(buf) + pdf_bytes = buf.getvalue() + + anchors = [] + for sig in SIGNATURE_FIELDS: + llx, lly, urx, ury = sig["rect"] + anchors.append({ + "field": sig["field"], + "page": sig["page"], + "x": float(llx) + 4, + "y": float(lly) + 1, + "w": float(urx - llx) - 8, + "h": max(float(ury - lly), 18.0), + "page_w": sig["page_w"], + "page_h": sig["page_h"], + }) + + missing = [] + if reason in ("change", "deactivation", "reactivation") and not values["npi"]: + missing.append(f"NPI is required for a CMS-10114 {reason} but was not provided.") + if not (values["first"] and values["last"]): + missing.append("Provider first/last name not collected — required on the certification.") + if reason == "deactivation": + missing.append("Deactivation reason (Death / Business Dissolved / Other) must be selected by reviewer.") + if reason == "reactivation": + missing.append("Reactivation reason free-text must be completed by reviewer.") + if reason == "change": + missing.append("Mark which specific fields are changing in Section 2/3 before mailing.") + # The overlay targets the individual (Section 4A / Section 2A) path. Org + # filings (Entity Type 2 / Section 4B) need the org variant. + if (intake.get("enumeration_type") or "").upper() in ("NPI-2", "2", "ORGANIZATION"): + missing.append("Organization (NPI-2) detected — complete Section 2B/4B; this overlay targets the individual path.") + + LOG.info("[cms10114] order=%s reason=%s npi=%s missing=%d", + order_number, reason, values["npi"], len(missing)) + return pdf_bytes, anchors, missing + + +if __name__ == "__main__": # quick local render for visual verification + sample = { + "provider_name": "Jane Q Smith", + "npi": "1234567893", + "practice_state": "CA", + "enumeration_type": "NPI-1", + } + for r in ("change", "reactivation", "deactivation"): + pdf, anchors, missing = fill_cms10114(sample, reason=r, order_number="CO-TEST") + out = f"/tmp/cms10114_{r}.pdf" + with open(out, "wb") as fh: + fh.write(pdf) + print(f"wrote {out} anchors={len(anchors)} missing={len(missing)}") diff --git a/scripts/tests/test_cms10114.py b/scripts/tests/test_cms10114.py new file mode 100644 index 0000000..3e5fe1f --- /dev/null +++ b/scripts/tests/test_cms10114.py @@ -0,0 +1,128 @@ +"""Tests for the CMS-10114 NPI Application/Update filler (Standard no-login path). + +Verifies the overlay-based filler: + - selects the correct Reason-for-Submittal checkbox per reason/slug + - places the NPI on the correct line for each reason + - writes the provider name into Section 2A and the Section 4A certification + - produces a signature anchor on the certification page + - surfaces the right "manual completion" notes + - mails to the verified Baltimore NPI Enumerator address (via mac_routing) + +Run: python scripts/tests/test_cms10114.py +""" +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "scripts")) + +import importlib.util # noqa: E402 + +import pdfplumber # noqa: E402 +from workers import mac_routing as mr # noqa: E402 + + +def _load_module(name, rel_path): + spec = importlib.util.spec_from_file_location(name, ROOT / rel_path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +# Load the filler directly to avoid document_gen/__init__ side-effects (python-docx). +f = _load_module("cms10114_pdf_filler", + "scripts/document_gen/templates/cms10114_pdf_filler.py") + +H = 792.0 +_fails = 0 + + +def check(name, cond): + global _fails + status = "PASS" if cond else "FAIL" + if not cond: + _fails += 1 + print(f" {status} {name}") + + +def _tokens(pdf_bytes, page_index): + import io + pdf = pdfplumber.open(io.BytesIO(pdf_bytes)) + page = pdf.pages[page_index] + return {w["text"]: (w["x0"], H - w["bottom"]) for w in page.extract_words()} + + +SAMPLE = { + "provider_name": "Jane Q Smith", + "npi": "1234567893", + "practice_state": "CA", + "enumeration_type": "NPI-1", +} + + +def main(): + print("reason normalization") + check("slug nppes-update -> change", f.normalize_reason("nppes-update") == "change") + check("slug npi-reactivation -> reactivation", f.normalize_reason("npi-reactivation") == "reactivation") + check("explicit 'deactivation' kept", f.normalize_reason("deactivation") == "deactivation") + check("unknown -> change (safe default)", f.normalize_reason("zzz") == "change") + + print("checkbox + NPI placement per reason (page 2)") + # Expected checkbox X positions (lower-left) per reason, from the form layout. + expect = { + "change": {"X": (48, 582), "NPI": (132, 570)}, + "reactivation": {"X": (325, 506), "NPI": (408, 529)}, + "deactivation": {"X": (325, 608), "NPI": (408, 599)}, + } + for reason, exp in expect.items(): + pdf, anchors, missing = f.fill_cms10114(SAMPLE, reason=reason) + toks = _tokens(pdf, f.PAGE_BASIC) + x = toks.get("X") + npi = toks.get("1234567893") + check(f"{reason}: X near {exp['X']}", + x and abs(x[0] - exp["X"][0]) < 6 and abs(x[1] - exp["X"][1]) < 6) + check(f"{reason}: NPI near {exp['NPI']}", + npi and abs(npi[0] - exp["NPI"][0]) < 6 and abs(npi[1] - exp["NPI"][1]) < 6) + + print("identity + certification name (Section 2A / 4A)") + pdf, anchors, missing = f.fill_cms10114(SAMPLE, reason="change") + p2 = _tokens(pdf, f.PAGE_BASIC) + p4 = _tokens(pdf, f.PAGE_CERT) + check("Section 2A first name placed", "Jane" in p2) + check("Section 2A last name placed", "Smith" in p2) + check("Section 4A cert first name placed", "Jane" in p4) + check("Section 4A cert last name placed", "Smith" in p4) + + print("signature anchor") + check("exactly one signature anchor", len(anchors) == 1) + check("anchor on certification page", anchors and anchors[0]["page"] == f.PAGE_CERT) + check("anchor field is 'signer'", anchors and anchors[0]["field"] == "signer") + + print("manual-completion notes") + _, _, m_change = f.fill_cms10114(SAMPLE, reason="change") + _, _, m_react = f.fill_cms10114(SAMPLE, reason="reactivation") + _, _, m_deact = f.fill_cms10114(SAMPLE, reason="deactivation") + check("change asks to mark changing fields", any("changing" in x.lower() for x in m_change)) + check("reactivation asks for reason text", any("reactivation reason" in x.lower() for x in m_react)) + check("deactivation asks for reason box", any("deactivation reason" in x.lower() for x in m_deact)) + _, _, m_noni = f.fill_cms10114({"provider_name": "No NPI"}, reason="change") + check("missing NPI flagged", any("npi is required" in x.lower() for x in m_noni)) + _, _, m_org = f.fill_cms10114({**SAMPLE, "enumeration_type": "NPI-2"}, reason="change") + check("org (NPI-2) flagged for 4B path", any("organization" in x.lower() for x in m_org)) + + print("routing destination = verified Baltimore Enumerator") + addr = " ".join(mr.NPI_ENUMERATOR.address_lines).lower() + check("destination is Baltimore (not Fargo)", "baltimore" in addr and "fargo" not in addr) + check("destination has the Mail Stop", "do-01-51" in addr) + + print() + if _fails: + print(f"FAILED: {_fails} checks") + sys.exit(1) + print("all CMS-10114 checks passed") + + +if __name__ == "__main__": + main() diff --git a/scripts/tests/test_paper_batch.py b/scripts/tests/test_paper_batch.py index ceeed6c..3e0e124 100644 --- a/scripts/tests/test_paper_batch.py +++ b/scripts/tests/test_paper_batch.py @@ -63,7 +63,7 @@ def test_routing(): tx = dpb._destination_for("TX", "cms855b") check("TX 855B -> Novitas JH", tx and tx[0] == "novitas_jh") enum = dpb._destination_for("TX", "cms10114") - check("any-state CMS-10114 -> NPI Enumerator (Fargo)", enum and enum[0] == "npi_enumerator") + check("any-state CMS-10114 -> NPI Enumerator (Baltimore)", enum and enum[0] == "npi_enumerator") check("CMS-10114 ignores state for routing", dpb._destination_for("CA", "cms10114")[0] == "npi_enumerator") check("unknown state -> None (human review)", dpb._destination_for("ZZ", "cms855i") is None) check("blank state -> None", dpb._destination_for("", "cms855i") is None) diff --git a/scripts/workers/daily_paper_batch.py b/scripts/workers/daily_paper_batch.py index e7042bc..21b3b17 100644 --- a/scripts/workers/daily_paper_batch.py +++ b/scripts/workers/daily_paper_batch.py @@ -48,8 +48,8 @@ def _is_postal_working_day(d: date) -> bool: def _destination_for(practice_state: str, document_type: str): """Return the (key, name, address_lines) destination for a filing. - NPPES/CMS-10114 updates go to the NPI Enumerator (Fargo); CMS-855s go to the - provider's MAC by state. + NPPES/CMS-10114 updates go to the NPI Enumerator (Baltimore, MD); CMS-855s go + to the provider's MAC by state. """ try: from scripts.workers import mac_routing as mr diff --git a/scripts/workers/mac_routing.py b/scripts/workers/mac_routing.py index 1dc48f5..cb21ee4 100644 --- a/scripts/workers/mac_routing.py +++ b/scripts/workers/mac_routing.py @@ -107,9 +107,13 @@ STATE_TO_MAC: dict[str, MAC] = { # NPI Enumerator paper address (NPPES / CMS-10114 paper path) — not a MAC, but a # destination the daily batch groups by, same as a MAC. +# VERIFIED 2025 against CMS-10114 (Rev. 02/25), OMB 0938-0931, page 5 "Or send the +# completed signed application to:". The earlier Fargo, ND PO Box 6059 address is +# RETIRED; the current address printed on the form is the Baltimore one below. NPI_ENUMERATOR = MAC( "npi_enumerator", "NPI Enumerator (NPPES / CMS-10114 paper)", - ("NPI Enumerator", "P.O. Box 6059", "Fargo, ND 58108-6059"), + ("CMS NPI Enumerator Services", "Mail Stop DO-01-51", + "7500 Security Blvd.", "Baltimore, MD 21244"), ) diff --git a/scripts/workers/services/npi_provider.py b/scripts/workers/services/npi_provider.py index c32ff80..19b3dc0 100644 --- a/scripts/workers/services/npi_provider.py +++ b/scripts/workers/services/npi_provider.py @@ -126,16 +126,22 @@ _SLUG_META = { # batched mailing). The bundle's revalidation piece is handled by the dedicated # revalidation order it spawns, so it is not listed here. # -# nppes-update is intentionally NOT here: its Standard path is the CMS-10114 (NPPES -# update mailed to the NPI Enumerator in Fargo), handled by a separate filler when -# built. Until then, nppes-update falls to the admin todo (Expedited if surrogate -# granted, otherwise manual CMS-10114 prep). +# nppes-update is NOT a CMS-855 filing: its Standard (no-login) path is the +# CMS-10114 NPI Application/Update form, signed by the provider and mailed to the +# NPI Enumerator (CMS NPI Enumerator Services, Baltimore MD). It is handled by +# the dedicated CMS-10114 filler below (_STANDARD_10114_SLUGS). _STANDARD_FILING_SLUGS = { "npi-revalidation", "npi-reactivation", "medicare-enrollment", } +# Slugs whose Standard path is the CMS-10114 (NPPES update/reactivation/deactivation +# mailed to the NPI Enumerator). The reason checkbox is derived from the slug. +_STANDARD_10114_SLUGS = { + "nppes-update", +} + class _BaseNPIHandler: """Shared review-staged behaviour for all NPI services.""" @@ -178,6 +184,14 @@ class _BaseNPIHandler: except Exception as exc: # never block the admin todo on PDF issues LOG.error("[%s] CMS-855 generation failed: %s", order_number, exc) filing_note = f"CMS-855 auto-generation FAILED ({exc}); prepare the form manually." + elif self.SERVICE_SLUG in _STANDARD_10114_SLUGS: + try: + filing_note = self._generate_10114_for_signing( + order_number, intake, provider, customer_email + ) + except Exception as exc: # never block the admin todo on PDF issues + LOG.error("[%s] CMS-10114 generation failed: %s", order_number, exc) + filing_note = f"CMS-10114 auto-generation FAILED ({exc}); prepare the form manually." surrogate_label = { "yes": "YES — client can grant I&A Surrogate access -> file the EXPEDITED way (online via surrogate).", @@ -267,16 +281,78 @@ class _BaseNPIHandler: note_lines.extend(f" - {m}" for m in missing) return "\n".join(note_lines) + def _generate_10114_for_signing(self, order_number, intake, provider, customer_email) -> str: + """Generate the official CMS-10114 (NPPES update path), upload it, and + request an e-signature. + + Standard (no-login) path for NPPES data updates / NPI reactivation: the + provider signs the certification and the signed form is mailed to the NPI + Enumerator (CMS NPI Enumerator Services, Baltimore MD) via the daily batch. + Returns a human-readable note for the admin todo. + """ + try: + from scripts.document_gen.templates.cms10114_pdf_filler import ( + fill_cms10114, normalize_reason, + ) + except ImportError: + from document_gen.templates.cms10114_pdf_filler import ( # type: ignore + fill_cms10114, normalize_reason, + ) + + # Reason comes from the explicit intake reason if present, else the slug. + reason = normalize_reason(intake.get("filing_reason") or self.SERVICE_SLUG) + pdf_bytes, anchors, missing = fill_cms10114(intake, reason=reason, order_number=order_number) + + document_key = f"compliance/{order_number}/cms10114_unsigned.pdf" + try: + import tempfile + try: + from scripts.document_gen.minio_client import MinioStorage + except ImportError: + from document_gen.minio_client import MinioStorage # type: ignore + storage = MinioStorage() + with tempfile.NamedTemporaryFile(suffix=".pdf", delete=True) as tf: + tf.write(pdf_bytes) + tf.flush() + storage.upload(tf.name, document_key, content_type="application/pdf") + except Exception as exc: + LOG.error("[%s] CMS-10114 upload failed: %s", order_number, exc) + return f"CMS-10114 generated but upload FAILED ({exc})." + + signed = self._create_855_esign_record( + order_number, intake, provider, customer_email, + form_type="10114", document_key=document_key, anchors=anchors, + document_type="cms10114", + document_title="NPI Application/Update Form (CMS-10114)", + ) + + note_lines = [ + f"CMS-10114 generated (reason: {reason}; official form, auto-filled where possible).", + f"Unsigned PDF: {document_key}", + ] + if signed and customer_email: + note_lines.append(f"E-sign link emailed to {customer_email}. After signing, the form joins the daily mail batch to the NPI Enumerator (standard) or file via NPPES surrogate access (expedited).") + else: + note_lines.append("No customer email or esign infra — send the form for signature manually.") + if missing: + note_lines.append("MANUAL COMPLETION NEEDED:") + note_lines.extend(f" - {m}" for m in missing) + return "\n".join(note_lines) + def _create_855_esign_record(self, order_number, intake, provider, customer_email, - form_type, document_key, anchors) -> bool: + form_type, document_key, anchors, + document_type=None, document_title=None) -> bool: """Create the esign record + email the signing link via the shared helper, then attach the official signature anchors. Returns True on success. + + Used for both CMS-855 and CMS-10114 (pass document_type/document_title to + override the defaults). """ if not customer_email: return False - document_type = f"cms{form_type}" - document_title = f"Medicare Enrollment Form (CMS-{form_type.upper()})" + document_type = document_type or f"cms{form_type}" + document_title = document_title or f"Medicare Enrollment Form (CMS-{form_type.upper()})" try: try: