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

BIN
docs/CMS-10114 Form.pdf Normal file

Binary file not shown.

View file

@ -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 - Paper-to-MAC, original signatures, routed by state. "Sign and date section 8
using ink." using ink."
### NPPES data update / NPI reactivation (CMS-10114 paper path) [PARTIAL] ### NPPES data update / NPI reactivation (CMS-10114 paper path) [CONFIRMED]
- 855I confirms NPI is obtained/changed via NPPES and "you may apply **online** at - **VERIFIED firsthand** against the live CMS-10114 PDF (Rev. 02/25, OMB
NPPES.cms.hhs.gov" — implies the online path; the paper CMS-10114 to the NPI 0938-0931, exp. 03/2028) downloaded from
Enumerator (PO Box 6059, Fargo ND 58108-6059) is the documented paper `cms.gov/Medicare/CMS-Forms/CMS-Forms/downloads/CMS10114.pdf`, and the CMS
alternative per the existing `healthcare-no-login-value-add.md` (NPI Enumerator "How to Apply" page which titles it the **"NPI Application/Update Form"**.
800-465-3203). - The form's Section 1A "Reason for Submittal" has four checkboxes:
- **UNCERTAIN (verify before relying on it as the sole standard path):** whether **#1 Initial Application, #2 Change of Information, #3 Deactivation,
CMS still accepts paper CMS-10114 for *updates/reactivation* (vs initial #4 Reactivation** — all completed on the SAME paper form, each requiring a
enumeration only) today. Could not reach the live CMS-10114 instruction from signed + dated certification (Section 4A individual / 4B organization).
this host (search engines blocked, direct URLs 404). Verbatim: *"If changing information, check box #2... then sign and date the
- **Decision:** for NPPES-only services, offer Expedited (surrogate → file in certification... All changes must be reported to the NPI Enumerator within 30
NPPES online) as the primary fast path, and the CMS-10114 paper path as the days."* and *"If you are reactivating the NPI, check box #4... Sign and date
Standard fallback. Confirm the CMS-10114-for-changes acceptance + obtain the the certification."*
current form PDF before building `cms10114_pdf_filler.py`. Until confirmed, the - So paper CMS-10114 is a valid no-login path for **changes AND reactivation**,
honest Standard fallback for a declined-surrogate NPPES update may be "we not just initial enumeration. UNCERTAIN flag resolved.
prepare it and guide the one-step online submission" — TBD on verification. - **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) ## CMS I&A Surrogate (Expedited path)
- Provider adds Performance West as a **Surrogate** in CMS I&A; we then file in - 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 revalidation | 855I/B → MAC, client signs | file in PECOS | 855B yes |
| Medicare enrollment | 855I/B/O → 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 | | 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 | | OIG/SAM screening | public DBs, **zero client action** | n/a (no portal) | no |
| Provider compliance bundle | spawns reval + screening + NPPES | mixed | per-piece | | Provider compliance bundle | spawns reval + screening + NPPES | mixed | per-piece |
## Daily batch (Standard-path mailing) ## Daily batch (Standard-path mailing)
Each **postal working day morning** (skip weekends + USPS/federal holidays — use Each **postal working day morning** (skip weekends + USPS/federal holidays — use
`scripts/workers/business_days.py` calendar): gather all signed+pending paper `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, 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 destination, date, enclosed count, per-item order#/provider/NPI/form), one
**Priority Mail** envelope per agency. Mark each order mailed with batch date + **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) ## Implementation status (built + validated)
- **mac_routing.py** — state→MAC (56 jurisdictions, 12 destinations) + - **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 - **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 - **checkout.ts + service pages + intake** — client-facing copy stripped of
mechanics; surrogate is the only optional, positively-framed ask (faster, mechanics; surrogate is the only optional, positively-framed ask (faster,
never required, never share password, never mentions paper). Astro build green. 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) ## TODO before first live mail (manual / verify)
1. Fill the real MAC provider-enrollment PO Box addresses in `mac_routing.py` 1. Fill the real MAC provider-enrollment PO Box addresses in `mac_routing.py`
(marked VERIFY) from each MAC's current enrollment page. (marked VERIFY) from each MAC's current enrollment page.
2. Confirm CMS-10114 paper-for-changes acceptance + obtain the form PDF, then 2. ~~Confirm CMS-10114 paper-for-changes acceptance + obtain the form PDF~~
build `cms10114_pdf_filler.py` for the NPPES Standard path (until then ~~build `cms10114_pdf_filler.py`~~ **DONE** — verified against CMS-10114
nppes-update falls to surrogate/manual). 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 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. 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 4. Phase 2: wire a print-mail API (Lob/Click2Mail) to auto-mail the merged PDF

View file

@ -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 | | 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." | | **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. | | **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. | | **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. | | **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.) > website. (For exclusion screening you don't even sign — we just do it.)
## Mailing destinations (for credibility in copy) ## 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; - 855 enrollment/revalidation: the provider's **MAC** (varies by state/jurisdiction;
we determine and route it). we determine and route it).

View file

@ -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 - **Standard service (default):** we handle the filing end-to-end. Client signs
ONE certification and we submit + track to confirmation. (Internally this is the 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.") 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 - **Expedited (faster, framed positively at intake):** we ask if they can
**electronically grant us CMS I&A Surrogate access**. We position this as **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 - `api/src/routes/checkout.ts` (~L2155-2207) — already has the two-tier copy, BUT
treats NPPES update/reactivation as **"online-only, surrogate required"** with treats NPPES update/reactivation as **"online-only, surrogate required"** with
NO standard fallback (`NPPES_ONLY_SLUGS`, `hasNppesOnly`). The directive says: 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. *expedited* option, not the only option.
- `scripts/workers/services/npi_provider.py``access` strings call NPPES - `scripts/workers/services/npi_provider.py``access` strings call NPPES
update/reactivation "NPPES via CMS I&A surrogate access (online-only)". Must 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 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. 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 - `scripts/document_gen/templates/cms855_pdf_filler.py` — working 855 paper path
@ -58,7 +58,7 @@ pricing changes.
## Approach (concrete ordered steps) ## Approach (concrete ordered steps)
1. **Confirm the CMS-10114 paper path** for NPPES data updates AND NPI 1. **Confirm the CMS-10114 paper path** for NPPES data updates AND NPI
reactivation (not just initial enumeration): paper CMS-10114 mailed to 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 Cite the official source. This is what makes NPPES services "Standard, no
login" instead of surrogate-required. (We currently have no CMS-10114 PDF.) 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: 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- paper + e-sign, and the admin todo reflects Standard-default / Expedited-if-
surrogate-granted. Mirror the existing 855 generate→upload→esign flow. surrogate-granted. Mirror the existing 855 generate→upload→esign flow.
5. **MAC + Fargo routing rule.** Document which Standard filings go where 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. `practice_state` intake field drives MAC envelope addressing.
7. **Daily batched-mail fulfillment (Standard path).** Design the operational 7. **Daily batched-mail fulfillment (Standard path).** Design the operational
flow for paper filings that are signed + pending submission: flow for paper filings that are signed + pending submission:
- On each **postal working day morning** (skip weekends + federal/USPS - On each **postal working day morning** (skip weekends + federal/USPS
holidays), gather ALL signed-and-pending paper filings, **group by 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). state Medicaid/CLIA agency).
- For each destination, **merge all that day's documents into one print job** - For each destination, **merge all that day's documents into one print job**
plus a **cover sheet** (Performance West sender, destination agency, date, plus a **cover sheet** (Performance West sender, destination agency, date,
@ -129,7 +129,7 @@ pricing changes.
and Standard is the default on every surface. and Standard is the default on every surface.
- **Standard path mechanism check:** `cms855_pdf_filler.fill_cms855("855i",...)` - **Standard path mechanism check:** `cms855_pdf_filler.fill_cms855("855i",...)`
still yields filled PDF + cert anchor (read-only smoke render). If CMS-10114 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` - **Expedited path check:** confirm the surrogate-grant CTA + `ia_surrogacy`
success action + admin todo together let a human file in PECOS/NPPES same-day; 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). 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. no "unknown" and no service that's portal-only-but-marketed-as-no-login.
## Open questions / decisions ## Open questions / decisions
1. **NPPES update/reactivation Standard path:** is CMS-10114 paper-to-Fargo 1. **NPPES update/reactivation Standard path:** ✅ RESOLVED — CMS-10114 paper IS
accepted for *changes/reactivation* today (RESOLVE in step 1)? If yes, both accepted for *changes AND reactivation* (verified against CMS-10114 Rev. 02/25,
become Standard-default + surrogate-expedited. If CMS has gone online-only, Section 1A boxes #2 Change / #4 Reactivation). Mailing address is the NPI
they stay surrogate-required and we say so honestly. (Directive assumes paper Enumerator in **Baltimore MD** (the old Fargo PO Box 6059 is retired). Both
works as Standard; step 1 verifies.) nppes-update and npi-reactivation are Standard-default + surrogate-expedited.
2. **Build `cms10114_pdf_filler.py` this pass?** Needed for a real NPPES Standard 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; path. Recommend: yes if step 1 confirms paper, mirroring the 855 filler;
otherwise defer. otherwise defer.

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") tx = dpb._destination_for("TX", "cms855b")
check("TX 855B -> Novitas JH", tx and tx[0] == "novitas_jh") check("TX 855B -> Novitas JH", tx and tx[0] == "novitas_jh")
enum = dpb._destination_for("TX", "cms10114") 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("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("unknown state -> None (human review)", dpb._destination_for("ZZ", "cms855i") is None)
check("blank state -> None", dpb._destination_for("", "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): def _destination_for(practice_state: str, document_type: str):
"""Return the (key, name, address_lines) destination for a filing. """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 NPPES/CMS-10114 updates go to the NPI Enumerator (Baltimore, MD); CMS-855s go
provider's MAC by state. to the provider's MAC by state.
""" """
try: try:
from scripts.workers import mac_routing as mr 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 # NPI Enumerator paper address (NPPES / CMS-10114 paper path) — not a MAC, but a
# destination the daily batch groups by, same as a MAC. # 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 = MAC(
"npi_enumerator", "NPI Enumerator (NPPES / CMS-10114 paper)", "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 # batched mailing). The bundle's revalidation piece is handled by the dedicated
# revalidation order it spawns, so it is not listed here. # revalidation order it spawns, so it is not listed here.
# #
# nppes-update is intentionally NOT here: its Standard path is the CMS-10114 (NPPES # nppes-update is NOT a CMS-855 filing: its Standard (no-login) path is the
# update mailed to the NPI Enumerator in Fargo), handled by a separate filler when # CMS-10114 NPI Application/Update form, signed by the provider and mailed to the
# built. Until then, nppes-update falls to the admin todo (Expedited if surrogate # NPI Enumerator (CMS NPI Enumerator Services, Baltimore MD). It is handled by
# granted, otherwise manual CMS-10114 prep). # the dedicated CMS-10114 filler below (_STANDARD_10114_SLUGS).
_STANDARD_FILING_SLUGS = { _STANDARD_FILING_SLUGS = {
"npi-revalidation", "npi-revalidation",
"npi-reactivation", "npi-reactivation",
"medicare-enrollment", "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: class _BaseNPIHandler:
"""Shared review-staged behaviour for all NPI services.""" """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 except Exception as exc: # never block the admin todo on PDF issues
LOG.error("[%s] CMS-855 generation failed: %s", order_number, exc) LOG.error("[%s] CMS-855 generation failed: %s", order_number, exc)
filing_note = f"CMS-855 auto-generation FAILED ({exc}); prepare the form manually." 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 = { surrogate_label = {
"yes": "YES — client can grant I&A Surrogate access -> file the EXPEDITED way (online via surrogate).", "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) note_lines.extend(f" - {m}" for m in missing)
return "\n".join(note_lines) 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, 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, """Create the esign record + email the signing link via the shared helper,
then attach the official signature anchors. Returns True on success. 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: if not customer_email:
return False return False
document_type = f"cms{form_type}" document_type = document_type or f"cms{form_type}"
document_title = f"Medicare Enrollment Form (CMS-{form_type.upper()})" document_title = document_title or f"Medicare Enrollment Form (CMS-{form_type.upper()})"
try: try:
try: try: