healthcare: verify CMS-10114 update path, correct NPI Enumerator address, build CMS-10114 filler

Verified firsthand against the live CMS-10114 (Rev. 02/25, OMB 0938-0931):
- Section 1A confirms paper is valid for Change of Information (#2) AND
  Reactivation (#4), not just initial enumeration. Resolves the UNCERTAIN flag.
- Current mailing address is CMS NPI Enumerator Services, Mail Stop DO-01-51,
  7500 Security Blvd, Baltimore MD 21244. The old Fargo PO Box 6059 is retired;
  corrected in mac_routing.NPI_ENUMERATOR + all docs.
- No electronic no-login equivalent exists for CMS (NPI Registry API is
  read-only; PECOS/NPPES-IA require login), unlike FMCSA's ask.fmcsa ticket form.
  So tiers stay: Standard=paper CMS-10114 (no login), Expedited=NPPES surrogate.

New: cms10114_pdf_filler.py fills the flat official form via text overlay
(reason checkbox + NPI + Section 2A identity + Section 4A cert name + signature
anchor); wired into npi_provider._generate_10114_for_signing for nppes-update.
Signed forms route to the NPI Enumerator via the existing daily batch.

Tests: test_cms10114.py 27/27, test_paper_batch.py 15/15, Astro build 58 pages.
This commit is contained in:
justin 2026-06-07 02:04:41 -05:00
parent f9c294e962
commit e6a630ada1
10 changed files with 540 additions and 48 deletions

View file

@ -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)}")

View file

@ -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()

View file

@ -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)

View file

@ -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

View file

@ -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"),
)

View file

@ -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: